diff --git a/.browserslistrc b/.browserslistrc index 427441dc9..6784945a5 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -8,10 +8,4 @@ # You can see what browsers were selected by your queries by running: # npx browserslist -last 1 Chrome version -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -Firefox ESR -not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. +defaults \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index c24677846..c82009e40 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,7 @@ # Editor configuration, see https://editorconfig.org root = true + [*] charset = utf-8 indent_style = space @@ -22,3 +23,7 @@ indent_size = 2 [*.csproj] indent_size = 2 + +[*.cs] +# Disable SonarLint warning S1075 (Don't use hardcoded url) +dotnet_diagnostic.S1075.severity = none diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 52c31b52b..805c3b61d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,10 +25,10 @@ body: - type: dropdown id: version attributes: - label: Kavita Version Number - If you don not see your version number listed, please update Kavita and see if your issue still persists. + label: Kavita Version Number - If you don't see your version number listed, please update Kavita and see if your issue still persists. multiple: false options: - - 0.8.4.2 - Stable + - 0.8.7 - Stable - Nightly Testing Branch validations: required: true diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 6aaef02d9..044864734 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -17,7 +17,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Install Swashbuckle CLI shell: powershell diff --git a/.github/workflows/canary-workflow.yml b/.github/workflows/canary-workflow.yml index 57ec316e4..b919030b0 100644 --- a/.github/workflows/canary-workflow.yml +++ b/.github/workflows/canary-workflow.yml @@ -9,7 +9,7 @@ on: jobs: build: name: Upload Kavita.Common for Version Bump - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout Repo uses: actions/checkout@v4 @@ -24,7 +24,7 @@ jobs: version: name: Bump version needs: [ build ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 with: @@ -33,7 +33,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Bump versions uses: SiqiLu/dotnet-bump-version@2.0.0 @@ -45,7 +45,7 @@ jobs: canary: name: Build Canary Docker needs: [ build, version ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: packages: write contents: read @@ -98,7 +98,7 @@ jobs: - name: Compile dotnet app uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Install Swashbuckle CLI run: dotnet tool install -g Swashbuckle.AspNetCore.Cli diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 391776dd8..7ce4276bc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,7 +13,7 @@ name: "CodeQL" on: push: - branches: [ "develop", "main" ] + branches: [ "develop"] pull_request: # The branches below must be a subset of the branches above branches: [ "develop" ] @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'csharp', 'javascript-typescript', 'python' ] + language: [ 'csharp', 'javascript-typescript' ] # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both @@ -48,9 +48,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Swashbuckle CLI - shell: bash - run: dotnet tool install -g Swashbuckle.AspNetCore.Cli + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/develop-workflow.yml b/.github/workflows/develop-workflow.yml index 939cda4e5..006127645 100644 --- a/.github/workflows/develop-workflow.yml +++ b/.github/workflows/develop-workflow.yml @@ -7,7 +7,7 @@ on: jobs: debug: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Debug Info run: | @@ -17,7 +17,7 @@ jobs: echo "Matches Develop: ${{ github.ref == 'refs/heads/develop' }}" build: name: Upload Kavita.Common for Version Bump - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.ref == 'refs/heads/develop' steps: - name: Checkout Repo @@ -33,7 +33,7 @@ jobs: version: name: Bump version needs: [ build ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.ref == 'refs/heads/develop' steps: - uses: actions/checkout@v4 @@ -43,7 +43,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Bump versions uses: majora2007/dotnet-bump-version@v0.0.10 @@ -55,7 +55,7 @@ jobs: develop: name: Build Nightly Docker needs: [ build, version ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.ref == 'refs/heads/develop' permissions: packages: write @@ -128,7 +128,7 @@ jobs: - name: Compile dotnet app uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Install Swashbuckle CLI run: dotnet tool install -g Swashbuckle.AspNetCore.Cli diff --git a/.github/workflows/openapi-gen.yml b/.github/workflows/openapi-gen.yml new file mode 100644 index 000000000..45446d045 --- /dev/null +++ b/.github/workflows/openapi-gen.yml @@ -0,0 +1,68 @@ +name: Generate OpenAPI Documentation + +on: + push: + branches: [ 'develop', '!release/**' ] + paths: + - '**/*.cs' + - '**/*.csproj' + pull_request: + branches: [ 'develop', '!release/**' ] + workflow_dispatch: + +jobs: + generate-openapi: + runs-on: ubuntu-latest + # Only run on direct pushes to develop, not PRs + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.repository_owner == 'Kareadita' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Install dependencies + run: dotnet restore + + - name: Build project + run: dotnet build API/API.csproj --configuration Debug + + - name: Get Swashbuckle version + id: swashbuckle-version + run: | + VERSION=$(grep -o '> $GITHUB_OUTPUT + echo "Found Swashbuckle.AspNetCore version: $VERSION" + + - name: Install matching Swashbuckle CLI tool + run: | + dotnet new tool-manifest --force + dotnet tool install Swashbuckle.AspNetCore.Cli --version ${{ steps.swashbuckle-version.outputs.VERSION }} + + - name: Generate OpenAPI file + run: dotnet swagger tofile --output openapi.json API/bin/Debug/net9.0/API.dll v1 + + - name: Check for changes + id: git-check + run: | + git add openapi.json + git diff --staged --quiet openapi.json || echo "has_changes=true" >> $GITHUB_OUTPUT + + - name: Commit and push if changed + if: steps.git-check.outputs.has_changes == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + git commit -m "Update OpenAPI documentation" openapi.json + + # Pull latest changes with rebase to avoid merge commits + git pull --rebase origin develop + + git push + env: + GITHUB_TOKEN: ${{ secrets.REPO_GHA_PAT }} diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 7482deb0b..51589221f 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -1,15 +1,13 @@ name: Validate PR Body on: - push: - branches: '**' pull_request: branches: [ main, develop, canary ] types: [synchronize] jobs: check_pr: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Extract branch name shell: bash diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index 95e4dc7e3..757ce1075 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -10,7 +10,7 @@ on: jobs: debug: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Debug Info run: | @@ -20,13 +20,13 @@ jobs: echo "Matches Develop: ${{ github.ref == 'refs/heads/develop' }}" if_merged: if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - run: | echo The PR was merged build: name: Upload Kavita.Common for Version Bump - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') steps: - name: Checkout Repo @@ -43,7 +43,7 @@ jobs: name: Build Stable and Nightly Docker if Release needs: [ build ] if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: packages: write contents: read @@ -106,7 +106,7 @@ jobs: - name: Compile dotnet app uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Install Swashbuckle CLI run: dotnet tool install -g Swashbuckle.AspNetCore.Cli diff --git a/.gitignore b/.gitignore index 71a904556..1cffb441d 100644 --- a/.gitignore +++ b/.gitignore @@ -513,6 +513,7 @@ UI/Web/dist/ /API/config/stats/ /API/config/bookmarks/ /API/config/favicons/ +/API/config/cache-long/ /API/config/kavita.db /API/config/kavita.db-shm /API/config/kavita.db-wal diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index 222213438..ec9c1884f 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 Exe @@ -10,8 +10,8 @@ - - + + @@ -26,5 +26,10 @@ Always + + + PreserveNewest + + diff --git a/API.Benchmark/Data/AesopsFables.epub b/API.Benchmark/Data/AesopsFables.epub new file mode 100644 index 000000000..d2ab9a8b2 Binary files /dev/null and b/API.Benchmark/Data/AesopsFables.epub differ diff --git a/API.Benchmark/KoreaderHashBenchmark.cs b/API.Benchmark/KoreaderHashBenchmark.cs new file mode 100644 index 000000000..c0abfd2ad --- /dev/null +++ b/API.Benchmark/KoreaderHashBenchmark.cs @@ -0,0 +1,41 @@ +using API.Helpers.Builders; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; +using System; +using API.Entities.Enums; + +namespace API.Benchmark +{ + [StopOnFirstError] + [MemoryDiagnoser] + [RankColumn] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + [SimpleJob(launchCount: 1, warmupCount: 5, invocationCount: 20)] + public class KoreaderHashBenchmark + { + private const string sourceEpub = "./Data/AesopsFables.epub"; + + [Benchmark(Baseline = true)] + public void TestBuildManga_baseline() + { + var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub) + .Build(); + if (file == null) + { + throw new Exception("Failed to build manga file"); + } + } + + [Benchmark] + public void TestBuildManga_withHash() + { + var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub) + .WithHash() + .Build(); + if (file == null) + { + throw new Exception("Failed to build manga file"); + } + } + } +} diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index df946c10b..a571a6e72 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -1,22 +1,22 @@ - net8.0 + net9.0 false - - + + - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -28,6 +28,7 @@ + @@ -35,4 +36,10 @@ + + + PreserveNewest + + + diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index 9f45ca619..9c5f3e726 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -1,6 +1,5 @@ -using System.Collections.Generic; +using System; using System.Data.Common; -using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -11,7 +10,6 @@ using API.Helpers.Builders; using API.Services; using AutoMapper; using Hangfire; -using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -20,40 +18,34 @@ using NSubstitute; namespace API.Tests; -public abstract class AbstractDbTest +public abstract class AbstractDbTest : AbstractFsTest , IDisposable { - protected readonly DbConnection _connection; - protected readonly DataContext _context; - protected readonly IUnitOfWork _unitOfWork; - - - protected const string CacheDirectory = "C:/kavita/config/cache/"; - protected const string CoverImageDirectory = "C:/kavita/config/covers/"; - protected const string BackupDirectory = "C:/kavita/config/backups/"; - protected const string LogDirectory = "C:/kavita/config/logs/"; - protected const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; - protected const string SiteThemeDirectory = "C:/kavita/config/themes/"; - protected const string TempDirectory = "C:/kavita/config/temp/"; - protected const string DataDirectory = "C:/data/"; + protected readonly DataContext Context; + protected readonly IUnitOfWork UnitOfWork; + protected readonly IMapper Mapper; + private readonly DbConnection _connection; + private bool _disposed; protected AbstractDbTest() { - var contextOptions = new DbContextOptionsBuilder() + var contextOptions = new DbContextOptionsBuilder() .UseSqlite(CreateInMemoryDatabase()) + .EnableSensitiveDataLogging() .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; - _context = new DataContext(contextOptions); + Context = new DataContext(contextOptions); + + Context.Database.EnsureCreated(); // Ensure DB schema is created + Task.Run(SeedDb).GetAwaiter().GetResult(); var config = new MapperConfiguration(cfg => cfg.AddProfile()); - var mapper = config.CreateMapper(); + Mapper = config.CreateMapper(); - // Set up Hangfire to use in-memory storage for testing GlobalConfiguration.Configuration.UseInMemoryStorage(); - - - _unitOfWork = new UnitOfWork(_context, mapper, null); + UnitOfWork = new UnitOfWork(Context, Mapper, null); } private static DbConnection CreateInMemoryDatabase() @@ -66,47 +58,79 @@ public abstract class AbstractDbTest private async Task SeedDb() { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); + try + { + await Context.Database.EnsureCreatedAsync(); + var filesystem = CreateFileSystem(); - await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + await Seed.SeedSettings(Context, new DirectoryService(Substitute.For>(), filesystem)); - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; + var setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; + setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); - setting.Value = BookmarkDirectory; + setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); + setting.Value = BookmarkDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync(); - setting.Value = "10"; + setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync(); + setting.Value = "10"; - _context.ServerSetting.Update(setting); + Context.ServerSetting.Update(setting); - _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) - .Build()); - return await _context.SaveChangesAsync() > 0; + + Context.Library.Add(new LibraryBuilder("Manga") + .WithAllowMetadataMatching(true) + .WithFolderPath(new FolderPathBuilder(DataDirectory).Build()) + .Build()); + + await Context.SaveChangesAsync(); + + await Seed.SeedMetadataSettings(Context); + + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[SeedDb] Error: {ex.Message}"); + return false; + } } protected abstract Task ResetDb(); - protected static MockFileSystem CreateFileSystem() + public void Dispose() { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory(SiteThemeDirectory); - fileSystem.AddDirectory(LogDirectory); - fileSystem.AddDirectory(TempDirectory); - fileSystem.AddDirectory(DataDirectory); + Dispose(true); + GC.SuppressFinalize(this); + } - return fileSystem; + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + + if (disposing) + { + Context?.Dispose(); + _connection?.Dispose(); + } + + _disposed = true; + } + + /// + /// Add a role to an existing User. Commits. + /// + /// + /// + protected async Task AddUserWithRole(int userId, string roleName) + { + var role = new AppRole { Id = userId, Name = roleName, NormalizedName = roleName.ToUpper() }; + + await Context.Roles.AddAsync(role); + await Context.UserRoles.AddAsync(new AppUserRole { UserId = userId, RoleId = userId }); + + await Context.SaveChangesAsync(); } } diff --git a/API.Tests/AbstractFsTest.cs b/API.Tests/AbstractFsTest.cs new file mode 100644 index 000000000..965a7ad78 --- /dev/null +++ b/API.Tests/AbstractFsTest.cs @@ -0,0 +1,44 @@ + + +using System.IO; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using API.Services.Tasks.Scanner.Parser; + +namespace API.Tests; + +public abstract class AbstractFsTest +{ + + protected static readonly string Root = Parser.NormalizePath(Path.GetPathRoot(Directory.GetCurrentDirectory())); + protected static readonly string ConfigDirectory = Root + "kavita/config/"; + protected static readonly string CacheDirectory = ConfigDirectory + "cache/"; + protected static readonly string CacheLongDirectory = ConfigDirectory + "cache-long/"; + protected static readonly string CoverImageDirectory = ConfigDirectory + "covers/"; + protected static readonly string BackupDirectory = ConfigDirectory + "backups/"; + protected static readonly string LogDirectory = ConfigDirectory + "logs/"; + protected static readonly string BookmarkDirectory = ConfigDirectory + "bookmarks/"; + protected static readonly string SiteThemeDirectory = ConfigDirectory + "themes/"; + protected static readonly string TempDirectory = ConfigDirectory + "temp/"; + protected static readonly string ThemesDirectory = ConfigDirectory + "theme"; + protected static readonly string DataDirectory = Root + "data/"; + + protected static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory(Root + "kavita/"); + fileSystem.AddDirectory(Root + "kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CacheLongDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(BookmarkDirectory); + fileSystem.AddDirectory(SiteThemeDirectory); + fileSystem.AddDirectory(LogDirectory); + fileSystem.AddDirectory(TempDirectory); + fileSystem.AddDirectory(DataDirectory); + fileSystem.AddDirectory(ThemesDirectory); + + return fileSystem; + } +} diff --git a/API.Tests/Converters/CronConverterTests.cs b/API.Tests/Converters/CronConverterTests.cs index 4e214e8f1..5568c89d0 100644 --- a/API.Tests/Converters/CronConverterTests.cs +++ b/API.Tests/Converters/CronConverterTests.cs @@ -1,5 +1,4 @@ using API.Helpers.Converters; -using Hangfire; using Xunit; namespace API.Tests.Converters; diff --git a/API.Tests/Data/AesopsFables.epub b/API.Tests/Data/AesopsFables.epub new file mode 100644 index 000000000..d2ab9a8b2 Binary files /dev/null and b/API.Tests/Data/AesopsFables.epub differ diff --git a/API.Tests/Extensions/ChapterListExtensionsTests.cs b/API.Tests/Extensions/ChapterListExtensionsTests.cs index d27903ca9..f19a0cede 100644 --- a/API.Tests/Extensions/ChapterListExtensionsTests.cs +++ b/API.Tests/Extensions/ChapterListExtensionsTests.cs @@ -142,7 +142,7 @@ public class ChapterListExtensionsTests CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false), }; - Assert.Equal(chapterList.First(), chapterList.GetFirstChapterWithFiles()); + Assert.Equal(chapterList[0], chapterList.GetFirstChapterWithFiles()); } [Fact] @@ -150,13 +150,13 @@ public class ChapterListExtensionsTests { var chapterList = new List() { - CreateChapter("darker than black", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), + CreateChapter("darker than black", Parser.DefaultChapter, CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false), }; - chapterList.First().Files = new List(); + chapterList[0].Files = new List(); - Assert.Equal(chapterList.Last(), chapterList.GetFirstChapterWithFiles()); + Assert.Equal(chapterList[^1], chapterList.GetFirstChapterWithFiles()); } @@ -181,7 +181,7 @@ public class ChapterListExtensionsTests CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) }; - chapterList[0].ReleaseDate = new DateTime(10, 1, 1); + chapterList[0].ReleaseDate = new DateTime(10, 1, 1, 0, 0, 0, DateTimeKind.Utc); chapterList[1].ReleaseDate = DateTime.MinValue; Assert.Equal(0, chapterList.MinimumReleaseYear()); @@ -196,8 +196,8 @@ public class ChapterListExtensionsTests CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) }; - chapterList[0].ReleaseDate = new DateTime(2002, 1, 1); - chapterList[1].ReleaseDate = new DateTime(2012, 2, 1); + chapterList[0].ReleaseDate = new DateTime(2002, 1, 1, 0, 0, 0, DateTimeKind.Utc); + chapterList[1].ReleaseDate = new DateTime(2012, 2, 1, 0, 0, 0, DateTimeKind.Utc); Assert.Equal(2002, chapterList.MinimumReleaseYear()); } diff --git a/API.Tests/Extensions/EncodeFormatExtensionsTests.cs b/API.Tests/Extensions/EncodeFormatExtensionsTests.cs new file mode 100644 index 000000000..a02de84aa --- /dev/null +++ b/API.Tests/Extensions/EncodeFormatExtensionsTests.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Entities.Enums; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class EncodeFormatExtensionsTests +{ + [Fact] + public void GetExtension_ShouldReturnCorrectExtensionForAllValues() + { + // Arrange + var expectedExtensions = new Dictionary + { + { EncodeFormat.PNG, ".png" }, + { EncodeFormat.WEBP, ".webp" }, + { EncodeFormat.AVIF, ".avif" } + }; + + // Act & Assert + foreach (var format in Enum.GetValues(typeof(EncodeFormat)).Cast()) + { + var extension = format.GetExtension(); + Assert.Equal(expectedExtensions[format], extension); + } + } + +} diff --git a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs index 325b19c5d..227dd2b32 100644 --- a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs +++ b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs @@ -7,7 +7,6 @@ using API.Extensions; using API.Helpers.Builders; using API.Services; using API.Services.Tasks.Scanner.Parser; -using API.Tests.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index 4ea9a5a4b..96d74b46d 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -1,11 +1,9 @@ using System.Collections.Generic; using System.Linq; -using API.Data; using API.Data.Misc; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; +using API.Entities.Person; using API.Extensions.QueryExtensions; using API.Helpers.Builders; using Xunit; @@ -69,7 +67,7 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] - [InlineData(false, 1)] + [InlineData(false, 2)] public void RestrictAgainstAgeRestriction_Genre_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) { var items = new List() @@ -96,7 +94,7 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] - [InlineData(false, 1)] + [InlineData(false, 2)] public void RestrictAgainstAgeRestriction_Tag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) { var items = new List() diff --git a/API.Tests/Extensions/SeriesFilterTests.cs b/API.Tests/Extensions/SeriesFilterTests.cs index 7d88ff4fe..ba42be8a1 100644 --- a/API.Tests/Extensions/SeriesFilterTests.cs +++ b/API.Tests/Extensions/SeriesFilterTests.cs @@ -24,9 +24,9 @@ public class SeriesFilterTests : AbstractDbTest { protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series); - _context.AppUser.RemoveRange(_context.AppUser); - await _context.SaveChangesAsync(); + Context.Series.RemoveRange(Context.Series); + Context.AppUser.RemoveRange(Context.AppUser); + await Context.SaveChangesAsync(); } #region HasProgress @@ -54,18 +54,18 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); // Create read progress on Partial and Full - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), + var readerService = new ReaderService(UnitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For()); // Select Partial and set pages read to 5 on first chapter - var partialSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + var partialSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2); var partialChapter = partialSeries.Volumes.First().Chapters.First(); Assert.True(await readerService.SaveReadingProgress(new ProgressDto() @@ -78,7 +78,7 @@ public class SeriesFilterTests : AbstractDbTest }, user.Id)); // Select Full and set pages read to 10 on first chapter - var fullSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(3); + var fullSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(3); var fullChapter = fullSeries.Volumes.First().Chapters.First(); Assert.True(await readerService.SaveReadingProgress(new ProgressDto() @@ -98,7 +98,7 @@ public class SeriesFilterTests : AbstractDbTest { var user = await SetupHasProgress(); - var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 50, user.Id) + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThan, 50, user.Id) .ToListAsync(); Assert.Single(queryResult); @@ -111,7 +111,7 @@ public class SeriesFilterTests : AbstractDbTest var user = await SetupHasProgress(); // Query series with progress <= 50% - var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 50, user.Id) + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 50, user.Id) .ToListAsync(); Assert.Equal(2, queryResult.Count); @@ -125,7 +125,7 @@ public class SeriesFilterTests : AbstractDbTest var user = await SetupHasProgress(); // Query series with progress > 50% - var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.GreaterThan, 50, user.Id) + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.GreaterThan, 50, user.Id) .ToListAsync(); Assert.Single(queryResult); @@ -138,7 +138,7 @@ public class SeriesFilterTests : AbstractDbTest var user = await SetupHasProgress(); // Query series with progress == 100% - var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.Equal, 100, user.Id) + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.Equal, 100, user.Id) .ToListAsync(); Assert.Single(queryResult); @@ -151,7 +151,7 @@ public class SeriesFilterTests : AbstractDbTest var user = await SetupHasProgress(); // Query series with progress < 100% - var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) .ToListAsync(); Assert.Equal(2, queryResult.Count); @@ -165,7 +165,7 @@ public class SeriesFilterTests : AbstractDbTest var user = await SetupHasProgress(); // Query series with progress <= 100% - var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 100, user.Id) + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 100, user.Id) .ToListAsync(); Assert.Equal(3, queryResult.Count); @@ -188,16 +188,16 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), + var readerService = new ReaderService(UnitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For()); // Set progress to 99.99% (99/100 pages read) - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); var chapter = series.Volumes.First().Chapters.First(); Assert.True(await readerService.SaveReadingProgress(new ProgressDto() @@ -210,7 +210,7 @@ public class SeriesFilterTests : AbstractDbTest }, user.Id)); // Query series with progress < 100% - var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) .ToListAsync(); Assert.Single(queryResult); @@ -246,9 +246,9 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); return user; } @@ -258,7 +258,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasLanguage(); - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Equal, ["en"]).ToListAsync(); + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Equal, ["en"]).ToListAsync(); Assert.Single(foundSeries); Assert.Equal("en", foundSeries[0].Metadata.Language); } @@ -268,7 +268,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasLanguage(); - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.NotEqual, ["en"]).ToListAsync(); + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.NotEqual, ["en"]).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.DoesNotContain(foundSeries, s => s.Metadata.Language == "en"); } @@ -278,7 +278,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasLanguage(); - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, ["en", "fr"]).ToListAsync(); + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Contains, ["en", "fr"]).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Metadata.Language == "en"); Assert.Contains(foundSeries, s => s.Metadata.Language == "fr"); @@ -289,7 +289,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasLanguage(); - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.NotContains, ["en", "fr"]).ToListAsync(); + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.NotContains, ["en", "fr"]).ToListAsync(); Assert.Single(foundSeries); Assert.Equal("es", foundSeries[0].Metadata.Language); } @@ -300,11 +300,11 @@ public class SeriesFilterTests : AbstractDbTest await SetupHasLanguage(); // Since "MustContains" matches all the provided languages, no series should match in this case. - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.MustContains, ["en", "fr"]).ToListAsync(); + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.MustContains, ["en", "fr"]).ToListAsync(); Assert.Empty(foundSeries); // Single language should work. - foundSeries = await _context.Series.HasLanguage(true, FilterComparison.MustContains, ["en"]).ToListAsync(); + foundSeries = await Context.Series.HasLanguage(true, FilterComparison.MustContains, ["en"]).ToListAsync(); Assert.Single(foundSeries); Assert.Equal("en", foundSeries[0].Metadata.Language); } @@ -314,7 +314,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasLanguage(); - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Matches, ["e"]).ToListAsync(); + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Matches, ["e"]).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains("en", foundSeries.Select(s => s.Metadata.Language)); Assert.Contains("es", foundSeries.Select(s => s.Metadata.Language)); @@ -325,7 +325,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasLanguage(); - var foundSeries = await _context.Series.HasLanguage(false, FilterComparison.Equal, ["en"]).ToListAsync(); + var foundSeries = await Context.Series.HasLanguage(false, FilterComparison.Equal, ["en"]).ToListAsync(); Assert.Equal(3, foundSeries.Count); } @@ -334,7 +334,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasLanguage(); - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Equal, new List()).ToListAsync(); + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Equal, new List()).ToListAsync(); Assert.Equal(3, foundSeries.Count); } @@ -345,7 +345,7 @@ public class SeriesFilterTests : AbstractDbTest await Assert.ThrowsAsync(async () => { - await _context.Series.HasLanguage(true, FilterComparison.GreaterThan, ["en"]).ToListAsync(); + await Context.Series.HasLanguage(true, FilterComparison.GreaterThan, ["en"]).ToListAsync(); }); } @@ -379,9 +379,9 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); return user; } @@ -391,7 +391,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAverageRating(); - var series = await _context.Series.HasAverageRating(true, FilterComparison.Equal, 100).ToListAsync(); + var series = await Context.Series.HasAverageRating(true, FilterComparison.Equal, 100).ToListAsync(); Assert.Single(series); Assert.Equal("Full", series[0].Name); } @@ -401,7 +401,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAverageRating(); - var series = await _context.Series.HasAverageRating(true, FilterComparison.GreaterThan, 50).ToListAsync(); + var series = await Context.Series.HasAverageRating(true, FilterComparison.GreaterThan, 50).ToListAsync(); Assert.Single(series); Assert.Equal("Full", series[0].Name); } @@ -411,7 +411,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAverageRating(); - var series = await _context.Series.HasAverageRating(true, FilterComparison.GreaterThanEqual, 50).ToListAsync(); + var series = await Context.Series.HasAverageRating(true, FilterComparison.GreaterThanEqual, 50).ToListAsync(); Assert.Equal(2, series.Count); Assert.Contains(series, s => s.Name == "Partial"); Assert.Contains(series, s => s.Name == "Full"); @@ -422,7 +422,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAverageRating(); - var series = await _context.Series.HasAverageRating(true, FilterComparison.LessThan, 50).ToListAsync(); + var series = await Context.Series.HasAverageRating(true, FilterComparison.LessThan, 50).ToListAsync(); Assert.Single(series); Assert.Equal("None", series[0].Name); } @@ -432,7 +432,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAverageRating(); - var series = await _context.Series.HasAverageRating(true, FilterComparison.LessThanEqual, 50).ToListAsync(); + var series = await Context.Series.HasAverageRating(true, FilterComparison.LessThanEqual, 50).ToListAsync(); Assert.Equal(2, series.Count); Assert.Contains(series, s => s.Name == "None"); Assert.Contains(series, s => s.Name == "Partial"); @@ -443,7 +443,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAverageRating(); - var series = await _context.Series.HasAverageRating(true, FilterComparison.NotEqual, 100).ToListAsync(); + var series = await Context.Series.HasAverageRating(true, FilterComparison.NotEqual, 100).ToListAsync(); Assert.Equal(2, series.Count); Assert.DoesNotContain(series, s => s.Name == "Full"); } @@ -453,7 +453,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAverageRating(); - var series = await _context.Series.HasAverageRating(false, FilterComparison.Equal, 100).ToListAsync(); + var series = await Context.Series.HasAverageRating(false, FilterComparison.Equal, 100).ToListAsync(); Assert.Equal(3, series.Count); } @@ -462,7 +462,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAverageRating(); - var series = await _context.Series.HasAverageRating(true, FilterComparison.Equal, -1).ToListAsync(); + var series = await Context.Series.HasAverageRating(true, FilterComparison.Equal, -1).ToListAsync(); Assert.Single(series); Assert.Equal("None", series[0].Name); } @@ -474,7 +474,7 @@ public class SeriesFilterTests : AbstractDbTest await Assert.ThrowsAsync(async () => { - await _context.Series.HasAverageRating(true, FilterComparison.Contains, 50).ToListAsync(); + await Context.Series.HasAverageRating(true, FilterComparison.Contains, 50).ToListAsync(); }); } @@ -485,7 +485,7 @@ public class SeriesFilterTests : AbstractDbTest await Assert.ThrowsAsync(async () => { - await _context.Series.HasAverageRating(true, (FilterComparison)999, 50).ToListAsync(); + await Context.Series.HasAverageRating(true, (FilterComparison)999, 50).ToListAsync(); }); } @@ -519,9 +519,9 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); return user; } @@ -531,7 +531,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasPublicationStatus(); - var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); Assert.Single(foundSeries); Assert.Equal("Cancelled", foundSeries[0].Name); } @@ -541,7 +541,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasPublicationStatus(); - var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Contains, new List { PublicationStatus.Cancelled, PublicationStatus.Completed }).ToListAsync(); + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.Contains, new List { PublicationStatus.Cancelled, PublicationStatus.Completed }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "Cancelled"); Assert.Contains(foundSeries, s => s.Name == "Completed"); @@ -552,7 +552,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasPublicationStatus(); - var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.NotContains, new List { PublicationStatus.Cancelled }).ToListAsync(); + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.NotContains, new List { PublicationStatus.Cancelled }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "OnGoing"); Assert.Contains(foundSeries, s => s.Name == "Completed"); @@ -563,7 +563,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasPublicationStatus(); - var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.NotEqual, new List { PublicationStatus.OnGoing }).ToListAsync(); + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.NotEqual, new List { PublicationStatus.OnGoing }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "Cancelled"); Assert.Contains(foundSeries, s => s.Name == "Completed"); @@ -574,7 +574,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasPublicationStatus(); - var foundSeries = await _context.Series.HasPublicationStatus(false, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); + var foundSeries = await Context.Series.HasPublicationStatus(false, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); Assert.Equal(3, foundSeries.Count); } @@ -583,7 +583,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasPublicationStatus(); - var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List()).ToListAsync(); + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List()).ToListAsync(); Assert.Equal(3, foundSeries.Count); } @@ -594,7 +594,7 @@ public class SeriesFilterTests : AbstractDbTest await Assert.ThrowsAsync(async () => { - await _context.Series.HasPublicationStatus(true, FilterComparison.BeginsWith, new List { PublicationStatus.Cancelled }).ToListAsync(); + await Context.Series.HasPublicationStatus(true, FilterComparison.BeginsWith, new List { PublicationStatus.Cancelled }).ToListAsync(); }); } @@ -605,7 +605,7 @@ public class SeriesFilterTests : AbstractDbTest await Assert.ThrowsAsync(async () => { - await _context.Series.HasPublicationStatus(true, (FilterComparison)999, new List { PublicationStatus.Cancelled }).ToListAsync(); + await Context.Series.HasPublicationStatus(true, (FilterComparison)999, new List { PublicationStatus.Cancelled }).ToListAsync(); }); } #endregion @@ -637,9 +637,9 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); return user; } @@ -649,7 +649,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Equal, [AgeRating.G]).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.Equal, [AgeRating.G]).ToListAsync(); Assert.Single(foundSeries); Assert.Equal("G", foundSeries[0].Name); } @@ -659,7 +659,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Contains, new List { AgeRating.G, AgeRating.Mature }).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.Contains, new List { AgeRating.G, AgeRating.Mature }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "G"); Assert.Contains(foundSeries, s => s.Name == "Mature"); @@ -670,7 +670,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.NotContains, new List { AgeRating.Unknown }).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.NotContains, new List { AgeRating.Unknown }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "G"); Assert.Contains(foundSeries, s => s.Name == "Mature"); @@ -681,7 +681,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.NotEqual, new List { AgeRating.G }).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.NotEqual, new List { AgeRating.G }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "Unknown"); Assert.Contains(foundSeries, s => s.Name == "Mature"); @@ -692,7 +692,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.GreaterThan, new List { AgeRating.Unknown }).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.GreaterThan, new List { AgeRating.Unknown }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "G"); Assert.Contains(foundSeries, s => s.Name == "Mature"); @@ -703,7 +703,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.GreaterThanEqual, new List { AgeRating.G }).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.GreaterThanEqual, new List { AgeRating.G }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "G"); Assert.Contains(foundSeries, s => s.Name == "Mature"); @@ -714,7 +714,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.LessThan, new List { AgeRating.Mature }).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.LessThan, new List { AgeRating.Mature }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "Unknown"); Assert.Contains(foundSeries, s => s.Name == "G"); @@ -725,7 +725,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.LessThanEqual, new List { AgeRating.G }).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.LessThanEqual, new List { AgeRating.G }).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "Unknown"); Assert.Contains(foundSeries, s => s.Name == "G"); @@ -736,7 +736,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(false, FilterComparison.Equal, new List { AgeRating.G }).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(false, FilterComparison.Equal, new List { AgeRating.G }).ToListAsync(); Assert.Equal(3, foundSeries.Count); } @@ -745,7 +745,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasAgeRating(); - var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Equal, new List()).ToListAsync(); + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.Equal, new List()).ToListAsync(); Assert.Equal(3, foundSeries.Count); } @@ -756,7 +756,7 @@ public class SeriesFilterTests : AbstractDbTest await Assert.ThrowsAsync(async () => { - await _context.Series.HasAgeRating(true, FilterComparison.BeginsWith, new List { AgeRating.G }).ToListAsync(); + await Context.Series.HasAgeRating(true, FilterComparison.BeginsWith, new List { AgeRating.G }).ToListAsync(); }); } @@ -767,7 +767,7 @@ public class SeriesFilterTests : AbstractDbTest await Assert.ThrowsAsync(async () => { - await _context.Series.HasAgeRating(true, (FilterComparison)999, new List { AgeRating.G }).ToListAsync(); + await Context.Series.HasAgeRating(true, (FilterComparison)999, new List { AgeRating.G }).ToListAsync(); }); } @@ -801,9 +801,9 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); return user; } @@ -813,7 +813,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasReleaseYear(); - var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.Equal, 2020).ToListAsync(); + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.Equal, 2020).ToListAsync(); Assert.Single(foundSeries); Assert.Equal("2020", foundSeries[0].Name); } @@ -823,7 +823,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasReleaseYear(); - var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.GreaterThan, 2000).ToListAsync(); + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.GreaterThan, 2000).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "2020"); Assert.Contains(foundSeries, s => s.Name == "2025"); @@ -834,7 +834,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasReleaseYear(); - var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.LessThan, 2025).ToListAsync(); + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.LessThan, 2025).ToListAsync(); Assert.Equal(2, foundSeries.Count); Assert.Contains(foundSeries, s => s.Name == "2000"); Assert.Contains(foundSeries, s => s.Name == "2020"); @@ -845,7 +845,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasReleaseYear(); - var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsInLast, 5).ToListAsync(); + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.IsInLast, 5).ToListAsync(); Assert.Equal(2, foundSeries.Count); } @@ -854,7 +854,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasReleaseYear(); - var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsNotInLast, 5).ToListAsync(); + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.IsNotInLast, 5).ToListAsync(); Assert.Single(foundSeries); Assert.Contains(foundSeries, s => s.Name == "2000"); } @@ -864,7 +864,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasReleaseYear(); - var foundSeries = await _context.Series.HasReleaseYear(false, FilterComparison.Equal, 2020).ToListAsync(); + var foundSeries = await Context.Series.HasReleaseYear(false, FilterComparison.Equal, 2020).ToListAsync(); Assert.Equal(3, foundSeries.Count); } @@ -873,7 +873,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasReleaseYear(); - var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.Equal, null).ToListAsync(); + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.Equal, null).ToListAsync(); Assert.Equal(3, foundSeries.Count); } @@ -889,10 +889,10 @@ public class SeriesFilterTests : AbstractDbTest .Build()) .Build(); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Library.Add(library); + await Context.SaveChangesAsync(); - var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsEmpty, 0).ToListAsync(); + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.IsEmpty, 0).ToListAsync(); Assert.Single(foundSeries); Assert.Equal("EmptyYear", foundSeries[0].Name); } @@ -925,28 +925,26 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); - - var seriesService = new SeriesService(_unitOfWork, Substitute.For(), - Substitute.For(), Substitute.For>(), - Substitute.For(), Substitute.For()); + var ratingService = new RatingService(UnitOfWork, Substitute.For(), Substitute.For>()); // Select 0 Rating - var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + var zeroRating = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + Assert.NotNull(zeroRating); - Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + Assert.True(await ratingService.UpdateSeriesRating(user, new UpdateRatingDto() { SeriesId = zeroRating.Id, UserRating = 0 })); // Select 4.5 Rating - var partialRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(3); + var partialRating = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(3); - Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + Assert.True(await ratingService.UpdateSeriesRating(user, new UpdateRatingDto() { SeriesId = partialRating.Id, UserRating = 4.5f @@ -960,7 +958,7 @@ public class SeriesFilterTests : AbstractDbTest { var user = await SetupHasRating(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasRating(true, FilterComparison.Equal, 4.5f, user.Id) .ToListAsync(); @@ -973,7 +971,7 @@ public class SeriesFilterTests : AbstractDbTest { var user = await SetupHasRating(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasRating(true, FilterComparison.GreaterThan, 0, user.Id) .ToListAsync(); @@ -986,7 +984,7 @@ public class SeriesFilterTests : AbstractDbTest { var user = await SetupHasRating(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasRating(true, FilterComparison.LessThan, 4.5f, user.Id) .ToListAsync(); @@ -999,7 +997,7 @@ public class SeriesFilterTests : AbstractDbTest { var user = await SetupHasRating(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasRating(true, FilterComparison.IsEmpty, 0, user.Id) .ToListAsync(); @@ -1012,7 +1010,7 @@ public class SeriesFilterTests : AbstractDbTest { var user = await SetupHasRating(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasRating(true, FilterComparison.GreaterThanEqual, 4.5f, user.Id) .ToListAsync(); @@ -1025,7 +1023,7 @@ public class SeriesFilterTests : AbstractDbTest { var user = await SetupHasRating(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasRating(true, FilterComparison.LessThanEqual, 0, user.Id) .ToListAsync(); @@ -1103,9 +1101,9 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); return user; } @@ -1115,7 +1113,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasName(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasName(true, FilterComparison.Equal, "My Dress-Up Darling") .ToListAsync(); @@ -1128,7 +1126,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasName(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasName(true, FilterComparison.Equal, "Ijiranaide, Nagatoro-san") .ToListAsync(); @@ -1141,7 +1139,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasName(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasName(true, FilterComparison.BeginsWith, "My Dress") .ToListAsync(); @@ -1154,7 +1152,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasName(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasName(true, FilterComparison.BeginsWith, "Sono Bisque") .ToListAsync(); @@ -1167,7 +1165,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasName(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasName(true, FilterComparison.EndsWith, "Nagatoro") .ToListAsync(); @@ -1180,7 +1178,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasName(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasName(true, FilterComparison.Matches, "Toy With Me") .ToListAsync(); @@ -1193,7 +1191,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasName(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasName(true, FilterComparison.NotEqual, "My Dress-Up Darling") .ToListAsync(); @@ -1237,9 +1235,9 @@ public class SeriesFilterTests : AbstractDbTest .WithLibrary(library) .Build(); - _context.Users.Add(user); - _context.Library.Add(library); - await _context.SaveChangesAsync(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); return user; } @@ -1249,7 +1247,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasSummary(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasSummary(true, FilterComparison.Equal, "I like hippos") .ToListAsync(); @@ -1262,7 +1260,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasSummary(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasSummary(true, FilterComparison.BeginsWith, "I like h") .ToListAsync(); @@ -1275,7 +1273,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasSummary(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasSummary(true, FilterComparison.EndsWith, "apples") .ToListAsync(); @@ -1288,7 +1286,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasSummary(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasSummary(true, FilterComparison.Matches, "like ducks") .ToListAsync(); @@ -1301,7 +1299,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasSummary(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasSummary(true, FilterComparison.NotEqual, "I like ducks") .ToListAsync(); @@ -1314,7 +1312,7 @@ public class SeriesFilterTests : AbstractDbTest { await SetupHasSummary(); - var foundSeries = await _context.Series + var foundSeries = await Context.Series .HasSummary(true, FilterComparison.IsEmpty, string.Empty) .ToListAsync(); diff --git a/API.Tests/Extensions/VersionExtensionTests.cs b/API.Tests/Extensions/VersionExtensionTests.cs new file mode 100644 index 000000000..e19fd7312 --- /dev/null +++ b/API.Tests/Extensions/VersionExtensionTests.cs @@ -0,0 +1,81 @@ +using System; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class VersionHelperTests +{ + [Fact] + public void CompareWithoutRevision_ShouldReturnTrue_WhenMajorMinorBuildMatch() + { + // Arrange + var v1 = new Version(1, 2, 3, 4); + var v2 = new Version(1, 2, 3, 5); + + // Act + var result = v1.CompareWithoutRevision(v2); + + // Assert + Assert.True(result); + } + + [Fact] + public void CompareWithoutRevision_ShouldHandleBuildlessVersions() + { + // Arrange + var v1 = new Version(1, 2); + var v2 = new Version(1, 2); + + // Act + var result = v1.CompareWithoutRevision(v2); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(1, 2, 3, 1, 2, 4)] + [InlineData(1, 2, 3, 1, 2, 0)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenBuildDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } + + [Theory] + [InlineData(1, 2, 3, 1, 3, 3)] + [InlineData(1, 2, 3, 1, 0, 3)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenMinorDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } + + [Theory] + [InlineData(1, 2, 3, 2, 2, 3)] + [InlineData(1, 2, 3, 0, 2, 3)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenMajorDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } +} diff --git a/API.Tests/Extensions/VolumeListExtensionsTests.cs b/API.Tests/Extensions/VolumeListExtensionsTests.cs index b8b734c51..bbb8f215c 100644 --- a/API.Tests/Extensions/VolumeListExtensionsTests.cs +++ b/API.Tests/Extensions/VolumeListExtensionsTests.cs @@ -3,7 +3,6 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers.Builders; -using API.Tests.Helpers; using Xunit; namespace API.Tests.Extensions; diff --git a/API.Tests/Helpers/BookSortTitlePrefixHelperTests.cs b/API.Tests/Helpers/BookSortTitlePrefixHelperTests.cs new file mode 100644 index 000000000..e1f585806 --- /dev/null +++ b/API.Tests/Helpers/BookSortTitlePrefixHelperTests.cs @@ -0,0 +1,178 @@ +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class BookSortTitlePrefixHelperTests +{ + [Theory] + [InlineData("The Avengers", "Avengers")] + [InlineData("A Game of Thrones", "Game of Thrones")] + [InlineData("An American Tragedy", "American Tragedy")] + public void TestEnglishPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("El Quijote", "Quijote")] + [InlineData("La Casa de Papel", "Casa de Papel")] + [InlineData("Los Miserables", "Miserables")] + [InlineData("Las Vegas", "Vegas")] + [InlineData("Un Mundo Feliz", "Mundo Feliz")] + [InlineData("Una Historia", "Historia")] + public void TestSpanishPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("Le Petit Prince", "Petit Prince")] + [InlineData("La Belle et la Bête", "Belle et la Bête")] + [InlineData("Les Misérables", "Misérables")] + [InlineData("Un Amour de Swann", "Amour de Swann")] + [InlineData("Une Vie", "Vie")] + [InlineData("Des Souris et des Hommes", "Souris et des Hommes")] + public void TestFrenchPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("Der Herr der Ringe", "Herr der Ringe")] + [InlineData("Die Verwandlung", "Verwandlung")] + [InlineData("Das Kapital", "Kapital")] + [InlineData("Ein Sommernachtstraum", "Sommernachtstraum")] + [InlineData("Eine Geschichte", "Geschichte")] + public void TestGermanPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("Il Nome della Rosa", "Nome della Rosa")] + [InlineData("La Divina Commedia", "Divina Commedia")] + [InlineData("Lo Hobbit", "Hobbit")] + [InlineData("Gli Ultimi", "Ultimi")] + [InlineData("Le Città Invisibili", "Città Invisibili")] + [InlineData("Un Giorno", "Giorno")] + [InlineData("Una Notte", "Notte")] + public void TestItalianPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("O Alquimista", "Alquimista")] + [InlineData("A Moreninha", "Moreninha")] + [InlineData("Os Lusíadas", "Lusíadas")] + [InlineData("As Meninas", "Meninas")] + [InlineData("Um Defeito de Cor", "Defeito de Cor")] + [InlineData("Uma História", "História")] + public void TestPortuguesePrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("", "")] // Empty string returns empty + [InlineData("Book", "Book")] // Single word, no change + [InlineData("Avengers", "Avengers")] // No prefix, no change + public void TestNoPrefixCases(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("The", "The")] // Just a prefix word alone + [InlineData("A", "A")] // Just single letter prefix alone + [InlineData("Le", "Le")] // French prefix alone + public void TestPrefixWordAlone(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("THE AVENGERS", "AVENGERS")] // All caps + [InlineData("the avengers", "avengers")] // All lowercase + [InlineData("The AVENGERS", "AVENGERS")] // Mixed case + [InlineData("tHe AvEnGeRs", "AvEnGeRs")] // Random case + public void TestCaseInsensitivity(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("Then Came You", "Then Came You")] // "The" + "n" = not a prefix + [InlineData("And Then There Were None", "And Then There Were None")] // "An" + "d" = not a prefix + [InlineData("Elsewhere", "Elsewhere")] // "El" + "sewhere" = not a prefix (no space) + [InlineData("Lesson Plans", "Lesson Plans")] // "Les" + "son" = not a prefix (no space) + [InlineData("Theory of Everything", "Theory of Everything")] // "The" + "ory" = not a prefix + public void TestFalsePositivePrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("The ", "The ")] // Prefix with only space after - returns original + [InlineData("La ", "La ")] // Same for other languages + [InlineData("El ", "El ")] // Same for Spanish + public void TestPrefixWithOnlySpaceAfter(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("The Multiple Spaces", " Multiple Spaces")] // Doesn't trim extra spaces from remainder + [InlineData("Le Petit Prince", " Petit Prince")] // Leading space preserved in remainder + public void TestSpaceHandling(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("The The Matrix", "The Matrix")] // Removes first "The", leaves second + [InlineData("A A Clockwork Orange", "A Clockwork Orange")] // Removes first "A", leaves second + [InlineData("El El Cid", "El Cid")] // Spanish version + public void TestRepeatedPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("L'Étranger", "L'Étranger")] // French contraction - no space, no change + [InlineData("D'Artagnan", "D'Artagnan")] // Contraction - no space, no change + [InlineData("The-Matrix", "The-Matrix")] // Hyphen instead of space - no change + [InlineData("The.Avengers", "The.Avengers")] // Period instead of space - no change + public void TestNonSpaceSeparators(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("三国演义", "三国演义")] // Chinese - no processing due to CJK detection + [InlineData("한국어", "한국어")] // Korean - not in CJK range, would be processed normally + public void TestCjkLanguages(string inputString, string expected) + { + // NOTE: These don't do anything, I am waiting for user input on if these are needed + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("नमस्ते दुनिया", "नमस्ते दुनिया")] // Hindi - not CJK, processed normally + [InlineData("مرحبا بالعالم", "مرحبا بالعالم")] // Arabic - not CJK, processed normally + [InlineData("שלום עולם", "שלום עולם")] // Hebrew - not CJK, processed normally + public void TestNonLatinNonCjkScripts(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("в мире", "мире")] // Russian "в" (in) - should be removed + [InlineData("на столе", "столе")] // Russian "на" (on) - should be removed + [InlineData("с друзьями", "друзьями")] // Russian "с" (with) - should be removed + public void TestRussianPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } +} diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs index 82f496a7b..3962ba2df 100644 --- a/API.Tests/Helpers/CacheHelperTests.cs +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.IO.Abstractions.TestingHelpers; -using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Helpers.Builders; @@ -11,9 +10,9 @@ using Xunit; namespace API.Tests.Helpers; -public class CacheHelperTests +public class CacheHelperTests: AbstractFsTest { - private const string TestCoverImageDirectory = @"c:\"; + private static readonly string TestCoverImageDirectory = Root; private const string TestCoverImageFile = "thumbnail.jpg"; private readonly string _testCoverPath = Path.Join(TestCoverImageDirectory, TestCoverImageFile); private const string TestCoverArchive = @"file in folder.zip"; @@ -37,24 +36,29 @@ public class CacheHelperTests [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_DoesFileExistRoot() + { + Assert.False(_cacheHelper.CoverImageExists(Root)); + } + [Fact] public void CoverImageExists_FileExists() { - Assert.True(_cacheHelper.CoverImageExists(TestCoverArchive)); + Assert.True(_cacheHelper.CoverImageExists(Path.Join(TestCoverImageDirectory, TestCoverArchive))); } [Fact] public void ShouldUpdateCoverImage_OnFirstRun() { - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.True(_cacheHelper.ShouldUpdateCoverImage(null, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -65,7 +69,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -76,7 +80,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked_2() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now, @@ -87,7 +91,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -98,7 +102,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked_Modified() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -122,7 +126,7 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var created = DateTime.Now.Subtract(TimeSpan.FromHours(1)); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now.Subtract(TimeSpan.FromMinutes(1))) .Build(); @@ -133,9 +137,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceCreated() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime =now, }; var fileSystem = new MockFileSystem(new Dictionary { @@ -147,12 +152,12 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var chapter = new ChapterBuilder("1") - .WithLastModified(filesystemFile.LastWriteTime.DateTime) - .WithCreated(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) + .WithCreated(now.DateTime) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } @@ -160,9 +165,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime = now, }; var fileSystem = new MockFileSystem(new Dictionary { @@ -174,12 +180,12 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var chapter = new ChapterBuilder("1") - .WithLastModified(filesystemFile.LastWriteTime.DateTime) - .WithCreated(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) + .WithCreated(now.DateTime) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); @@ -188,9 +194,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified_ForceUpdate() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime = now.DateTime, }; var fileSystem = new MockFileSystem(new Dictionary { @@ -202,12 +209,12 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var chapter = new ChapterBuilder("1") - .WithLastModified(filesystemFile.LastWriteTime.DateTime) - .WithCreated(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) + .WithCreated(now.DateTime) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, true, file)); } @@ -215,10 +222,11 @@ public class CacheHelperTests [Fact] public void IsFileUnmodifiedSinceCreationOrLastScan_ModifiedSinceLastScan() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now, - CreationTime = DateTimeOffset.Now + LastWriteTime = now.DateTime, + CreationTime = now.DateTime }; var fileSystem = new MockFileSystem(new Dictionary { @@ -234,8 +242,8 @@ public class CacheHelperTests .WithCreated(DateTime.Now.Subtract(TimeSpan.FromMinutes(10))) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } @@ -243,9 +251,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_ModifiedSinceLastScan_ButLastModifiedSame() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime =now.DateTime }; var fileSystem = new MockFileSystem(new Dictionary { @@ -262,7 +271,7 @@ public class CacheHelperTests .Build(); var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) .Build(); Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); diff --git a/API.Tests/Helpers/KoreaderHelperTests.cs b/API.Tests/Helpers/KoreaderHelperTests.cs new file mode 100644 index 000000000..66d287a5d --- /dev/null +++ b/API.Tests/Helpers/KoreaderHelperTests.cs @@ -0,0 +1,60 @@ +using API.DTOs.Koreader; +using API.DTOs.Progress; +using API.Helpers; +using System.Runtime.CompilerServices; +using Xunit; + +namespace API.Tests.Helpers; + + +public class KoreaderHelperTests +{ + + [Theory] + [InlineData("/body/DocFragment[11]/body/div/a", 10, null)] + [InlineData("/body/DocFragment[1]/body/div/p[40]", 0, 40)] + [InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, 28)] + public void GetEpubPositionDto(string koreaderPosition, int page, int? pNumber) + { + var expected = EmptyProgressDto(); + expected.BookScrollId = pNumber.HasValue ? $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[{pNumber}]" : null; + expected.PageNum = page; + var actual = EmptyProgressDto(); + + KoreaderHelper.UpdateProgressDto(actual, koreaderPosition); + Assert.Equal(expected.BookScrollId, actual.BookScrollId); + Assert.Equal(expected.PageNum, actual.PageNum); + } + + + [Theory] + [InlineData("//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[20]", 5, "/body/DocFragment[6]/body/div/p[20]")] + [InlineData(null, 10, "/body/DocFragment[11]/body/div/a")] + public void GetKoreaderPosition(string scrollId, int page, string koreaderPosition) + { + var given = EmptyProgressDto(); + given.BookScrollId = scrollId; + given.PageNum = page; + + Assert.Equal(koreaderPosition, KoreaderHelper.GetKoreaderPosition(given)); + } + + [Theory] + [InlineData("./Data/AesopsFables.epub", "8795ACA4BF264B57C1EEDF06A0CEE688")] + public void GetKoreaderHash(string filePath, string hash) + { + Assert.Equal(KoreaderHelper.HashContents(filePath), hash); + } + + private ProgressDto EmptyProgressDto() + { + return new ProgressDto + { + ChapterId = 0, + PageNum = 0, + VolumeId = 0, + SeriesId = 0, + LibraryId = 0 + }; + } +} diff --git a/API.Tests/Helpers/OrderableHelperTests.cs b/API.Tests/Helpers/OrderableHelperTests.cs index a6d741be1..15f9e6268 100644 --- a/API.Tests/Helpers/OrderableHelperTests.cs +++ b/API.Tests/Helpers/OrderableHelperTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Entities; using API.Helpers; @@ -49,17 +50,14 @@ public class OrderableHelperTests [Fact] public void ReorderItems_InvalidPosition_NoChange() { - // Arrange var items = new List { new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" }, new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" }, }; - // Act OrderableHelper.ReorderItems(items, 2, 3); // Position 3 is out of range - // Assert Assert.Equal(1, items[0].Id); // Item 1 should remain at position 0 Assert.Equal(2, items[1].Id); // Item 2 should remain at position 1 } @@ -80,7 +78,6 @@ public class OrderableHelperTests [Fact] public void ReorderItems_DoubleMove() { - // Arrange var items = new List { new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" }, @@ -94,7 +91,6 @@ public class OrderableHelperTests // Move 4 -> 1 OrderableHelper.ReorderItems(items, 5, 1); - // Assert Assert.Equal(1, items[0].Id); Assert.Equal(0, items[0].Order); Assert.Equal(5, items[1].Id); @@ -109,4 +105,98 @@ public class OrderableHelperTests Assert.Equal("034125", string.Join("", items.Select(s => s.Name))); } + + private static List CreateTestReadingListItems(int count = 4) + { + var items = new List(); + + for (var i = 0; i < count; i++) + { + items.Add(new ReadingListItem() { Id = i + 1, Order = count, ReadingListId = i + 1}); + } + + return items; + } + + [Fact] + public void ReorderItems_MoveItemToBeginning_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 3, 0); + + Assert.Equal(3, items[0].Id); + Assert.Equal(1, items[1].Id); + Assert.Equal(2, items[2].Id); + Assert.Equal(4, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToEnd_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 1, 3); + + Assert.Equal(2, items[0].Id); + Assert.Equal(3, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(1, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToMiddle_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 4, 2); + + Assert.Equal(1, items[0].Id); + Assert.Equal(2, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(3, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToOutOfBoundsPosition_MovesToEnd() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 2, 10); + + Assert.Equal(1, items[0].Id); + Assert.Equal(3, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(2, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_NegativePosition_ThrowsArgumentException() + { + var items = CreateTestReadingListItems(); + + Assert.Throws(() => + OrderableHelper.ReorderItems(items, 2, -1) + ); + } } diff --git a/API.Tests/Helpers/ParserInfoHelperTests.cs b/API.Tests/Helpers/ParserInfoHelperTests.cs index 70ce3aa69..0bb7efb9b 100644 --- a/API.Tests/Helpers/ParserInfoHelperTests.cs +++ b/API.Tests/Helpers/ParserInfoHelperTests.cs @@ -1,8 +1,5 @@ using System.Collections.Generic; -using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services.Tasks.Scanner; diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs index a25af7a07..47dab48da 100644 --- a/API.Tests/Helpers/PersonHelperTests.cs +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -1,14 +1,9 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.Data; -using API.DTOs; -using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Helpers.Builders; -using API.Services.Tasks.Scanner.Parser; using Xunit; namespace API.Tests.Helpers; @@ -17,127 +12,215 @@ public class PersonHelperTests : AbstractDbTest { protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); - await _context.SaveChangesAsync(); + Context.Series.RemoveRange(Context.Series.ToList()); + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); + await Context.SaveChangesAsync(); } - // - // // 1. Test adding new people and keeping existing ones - // [Fact] - // public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() - // { - // var existingPerson = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // // Create an existing person and assign them to the series with a role - // var series = new SeriesBuilder("Test 1") - // .WithFormat(MangaFormat.Archive) - // .WithMetadata(new SeriesMetadataBuilder() - // .WithPerson(existingPerson, PersonRole.Editor) - // .Build()) - // .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with one existing and one new person - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo", "New Person" }, PersonRole.Editor, _unitOfWork); - // - // // Assert existing person retained and new person added - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.Contains(people, p => p.Name == "Joe Shmo"); - // Assert.Contains(people, p => p.Name == "New Person"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.Contains("New Person", chapterPeople); - // } - // - // // 2. Test removing a person no longer in the list - // [Fact] - // public async Task UpdateChapterPeopleAsync_RemovePeople() - // { - // var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); - // var existingPerson2 = new PersonBuilder("Jane Doe").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(existingPerson1, PersonRole.Editor) - // .WithPerson(existingPerson2, PersonRole.Editor) - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with only one person - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.DoesNotContain("Jane Doe", chapterPeople); - // } - // - // // 3. Test no changes when the list of people is the same - // [Fact] - // public async Task UpdateChapterPeopleAsync_NoChanges() - // { - // var existingPerson = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(existingPerson, PersonRole.Editor) - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with the same list - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.Contains(people, p => p.Name == "Joe Shmo"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.Single(chapter.People); // No duplicate entries - // } - // - // // 4. Test multiple roles for a person - // [Fact] - // public async Task UpdateChapterPeopleAsync_MultipleRoles() - // { - // var person = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(person, PersonRole.Writer) // Assign person as Writer - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Add same person as Editor - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // // Ensure that the same person is assigned with two roles - // var chapterPeople = chapter.People.Where(cp => cp.Person.Name == "Joe Shmo").ToList(); - // Assert.Equal(2, chapterPeople.Count); // One for each role - // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); - // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); - // } + + // 1. Test adding new people and keeping existing ones + [Fact] + public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").Build(); + + // Create an existing person and assign them to the series with a role + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(existingPerson, PersonRole.Editor) + .Build()) + .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with one existing and one new person + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo", "New Person" }, PersonRole.Editor, UnitOfWork); + + // Assert existing person retained and new person added + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Contains(people, p => p.Name == "Joe Shmo"); + Assert.Contains(people, p => p.Name == "New Person"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.Contains("New Person", chapterPeople); + } + + // 2. Test removing a person no longer in the list + [Fact] + public async Task UpdateChapterPeopleAsync_RemovePeople() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); + var existingPerson2 = new PersonBuilder("Jane Doe").Build(); + var chapter = new ChapterBuilder("1") + .WithPerson(existingPerson1, PersonRole.Editor) + .WithPerson(existingPerson2, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with only one person + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + // PersonHelper does not remove the Person from the global DbSet itself + await UnitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.DoesNotContain("Jane Doe", chapterPeople); + } + + // 3. Test no changes when the list of people is the same + [Fact] + public async Task UpdateChapterPeopleAsync_NoChanges() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").WithPerson(existingPerson, PersonRole.Editor).Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with the same list + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Contains(people, p => p.Name == "Joe Shmo"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.Single(chapter.People); // No duplicate entries + } + + // 4. Test multiple roles for a person + [Fact] + public async Task UpdateChapterPeopleAsync_MultipleRoles() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var person = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").WithPerson(person, PersonRole.Writer).Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Add same person as Editor + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + // Ensure that the same person is assigned with two roles + var chapterPeople = chapter + .People + .Where(cp => + cp.Person.Name == "Joe Shmo") + .ToList(); + Assert.Equal(2, chapterPeople.Count); // One for each role + Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); + Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); + } + + [Fact] + public async Task UpdateChapterPeopleAsync_MatchOnAlias_NoChanges() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var person = new PersonBuilder("Joe Doe") + .WithAlias("Jonny Doe") + .Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Add on Name + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Doe" }, PersonRole.Editor, UnitOfWork); + await UnitOfWork.CommitAsync(); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + // Add on alias + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Jonny Doe" }, PersonRole.Editor, UnitOfWork); + await UnitOfWork.CommitAsync(); + + allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + } + + // TODO: Unit tests for series } diff --git a/API.Tests/Helpers/RandfHelper.cs b/API.Tests/Helpers/RandfHelper.cs new file mode 100644 index 000000000..d8c007df7 --- /dev/null +++ b/API.Tests/Helpers/RandfHelper.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace API.Tests.Helpers; + +public class RandfHelper +{ + private static readonly Random Random = new (); + + /// + /// Returns true if all simple fields are equal + /// + /// + /// + /// fields to ignore, note that the names are very weird sometimes + /// + /// + /// + public static bool AreSimpleFieldsEqual(object obj1, object obj2, IList ignoreFields) + { + if (obj1 == null || obj2 == null) + throw new ArgumentNullException("Neither object can be null."); + + Type type1 = obj1.GetType(); + Type type2 = obj2.GetType(); + + if (type1 != type2) + throw new ArgumentException("Objects must be of the same type."); + + FieldInfo[] fields = type1.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic); + + foreach (var field in fields) + { + if (field.IsInitOnly) continue; + if (ignoreFields.Contains(field.Name)) continue; + + Type fieldType = field.FieldType; + + if (IsRelevantType(fieldType)) + { + object value1 = field.GetValue(obj1); + object value2 = field.GetValue(obj2); + + if (!Equals(value1, value2)) + { + throw new ArgumentException("Fields must be of the same type: " + field.Name + " was " + value1 + " and " + value2); + } + } + } + + return true; + } + + private static bool IsRelevantType(Type type) + { + return type.IsPrimitive + || type == typeof(string) + || type.IsEnum; + } + + /// + /// Sets all simple fields of the given object to a random value + /// + /// + /// Simple is, primitive, string, or enum + /// + public static void SetRandomValues(object obj) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + + Type type = obj.GetType(); + FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + foreach (var field in fields) + { + if (field.IsInitOnly) continue; // Skip readonly fields + + object value = GenerateRandomValue(field.FieldType); + if (value != null) + { + field.SetValue(obj, value); + } + } + } + + private static object GenerateRandomValue(Type type) + { + if (type == typeof(int)) + return Random.Next(); + if (type == typeof(float)) + return (float)Random.NextDouble() * 100; + if (type == typeof(double)) + return Random.NextDouble() * 100; + if (type == typeof(bool)) + return Random.Next(2) == 1; + if (type == typeof(char)) + return (char)Random.Next('A', 'Z' + 1); + if (type == typeof(byte)) + return (byte)Random.Next(0, 256); + if (type == typeof(short)) + return (short)Random.Next(short.MinValue, short.MaxValue); + if (type == typeof(long)) + return (long)(Random.NextDouble() * long.MaxValue); + if (type == typeof(string)) + return GenerateRandomString(10); + if (type.IsEnum) + { + var values = Enum.GetValues(type); + return values.GetValue(Random.Next(values.Length)); + } + + // Unsupported type + return null; + } + + private static string GenerateRandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[Random.Next(s.Length)]).ToArray()); + } +} diff --git a/API.Tests/Helpers/RateLimiterTests.cs b/API.Tests/Helpers/RateLimiterTests.cs index c05ce4e6d..e9b0030b9 100644 --- a/API.Tests/Helpers/RateLimiterTests.cs +++ b/API.Tests/Helpers/RateLimiterTests.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using API.Helpers; using Xunit; @@ -33,7 +34,7 @@ public class RateLimiterTests } [Fact] - public void AcquireTokens_Refill() + public async Task AcquireTokens_Refill() { // Arrange var limiter = new RateLimiter(2, TimeSpan.FromSeconds(1)); @@ -43,14 +44,14 @@ public class RateLimiterTests limiter.TryAcquire("test_key"); // Wait for refill - System.Threading.Thread.Sleep(1100); + await Task.Delay(1100); // Assert Assert.True(limiter.TryAcquire("test_key")); } [Fact] - public void AcquireTokens_Refill_WithOff() + public async Task AcquireTokens_Refill_WithOff() { // Arrange var limiter = new RateLimiter(2, TimeSpan.FromSeconds(10), false); @@ -60,7 +61,7 @@ public class RateLimiterTests limiter.TryAcquire("test_key"); // Wait for refill - System.Threading.Thread.Sleep(2100); + await Task.Delay(2100); // Assert Assert.False(limiter.TryAcquire("test_key")); diff --git a/API.Tests/Helpers/ReviewHelperTests.cs b/API.Tests/Helpers/ReviewHelperTests.cs new file mode 100644 index 000000000..b221c3c70 --- /dev/null +++ b/API.Tests/Helpers/ReviewHelperTests.cs @@ -0,0 +1,258 @@ +using API.Helpers; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using API.DTOs.SeriesDetail; + +namespace API.Tests.Helpers; + +public class ReviewHelperTests +{ + #region SelectSpectrumOfReviews Tests + + [Fact] + public void SelectSpectrumOfReviews_WhenLessThan10Reviews_ReturnsAllReviews() + { + // Arrange + var reviews = CreateReviewList(8); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(8, result.Count); + Assert.Equal(reviews, result.OrderByDescending(r => r.Score)); + } + + [Fact] + public void SelectSpectrumOfReviews_WhenMoreThan10Reviews_Returns10Reviews() + { + // Arrange + var reviews = CreateReviewList(20); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + Assert.Equal(reviews[0], result.First()); + Assert.Equal(reviews[19], result.Last()); + } + + [Fact] + public void SelectSpectrumOfReviews_WithExactly10Reviews_ReturnsAllReviews() + { + // Arrange + var reviews = CreateReviewList(10); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + } + + [Fact] + public void SelectSpectrumOfReviews_WithLargeNumberOfReviews_ReturnsCorrectSpectrum() + { + // Arrange + var reviews = CreateReviewList(100); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + Assert.Contains(reviews[0], result); + Assert.Contains(reviews[1], result); + Assert.Contains(reviews[98], result); + Assert.Contains(reviews[99], result); + } + + [Fact] + public void SelectSpectrumOfReviews_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var reviews = new List(); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void SelectSpectrumOfReviews_ResultsOrderedByScoreDescending() + { + // Arrange + var reviews = new List + { + new UserReviewDto { Tagline = "1", Score = 3 }, + new UserReviewDto { Tagline = "2", Score = 5 }, + new UserReviewDto { Tagline = "3", Score = 1 }, + new UserReviewDto { Tagline = "4", Score = 4 }, + new UserReviewDto { Tagline = "5", Score = 2 } + }; + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal(5, result[0].Score); + Assert.Equal(4, result[1].Score); + Assert.Equal(3, result[2].Score); + Assert.Equal(2, result[3].Score); + Assert.Equal(1, result[4].Score); + } + + #endregion + + #region GetCharacters Tests + + [Fact] + public void GetCharacters_WithNullBody_ReturnsNull() + { + // Arrange + string body = null; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetCharacters_WithEmptyBody_ReturnsEmptyString() + { + // Arrange + var body = string.Empty; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCharacters_WithNoTextNodes_ReturnsEmptyString() + { + // Arrange + const string body = "
"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCharacters_WithLessCharactersThanLimit_ReturnsFullText() + { + // Arrange + var body = "

This is a short review.

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("This is a short review.…", result); + } + + [Fact] + public void GetCharacters_WithMoreCharactersThanLimit_TruncatesText() + { + // Arrange + var body = "

" + new string('a', 200) + "

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(new string('a', 175) + "…", result); + Assert.Equal(176, result.Length); // 175 characters + ellipsis + } + + [Fact] + public void GetCharacters_IgnoresScriptTags() + { + // Arrange + const string body = "

Visible text

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("Visible text…", result); + Assert.DoesNotContain("hidden", result); + } + + [Fact] + public void GetCharacters_RemovesMarkdownSymbols() + { + // Arrange + const string body = "

This is **bold** and _italic_ text with [link](url).

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("This is bold and italic text with link.…", result); + } + + [Fact] + public void GetCharacters_HandlesComplexMarkdownAndHtml() + { + // Arrange + const string body = """ + +
+

# Header

+

This is ~~strikethrough~~ and __underlined__ text

+

~~~code block~~~

+

+++highlighted+++

+

img123(image.jpg)

+
+ """; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.DoesNotContain("~~", result); + Assert.DoesNotContain("__", result); + Assert.DoesNotContain("~~~", result); + Assert.DoesNotContain("+++", result); + Assert.DoesNotContain("img123(", result); + Assert.Contains("Header", result); + Assert.Contains("strikethrough", result); + Assert.Contains("underlined", result); + Assert.Contains("code block", result); + Assert.Contains("highlighted", result); + } + + #endregion + + #region Helper Methods + + private static List CreateReviewList(int count) + { + var reviews = new List(); + for (var i = 0; i < count; i++) + { + reviews.Add(new UserReviewDto + { + Tagline = $"{i + 1}", + Score = count - i // This makes them ordered by score descending initially + }); + } + return reviews; + } + + #endregion +} + diff --git a/API.Tests/Helpers/ScannerHelper.cs b/API.Tests/Helpers/ScannerHelper.cs index e164d015e..653efebb1 100644 --- a/API.Tests/Helpers/ScannerHelper.cs +++ b/API.Tests/Helpers/ScannerHelper.cs @@ -26,6 +26,7 @@ using NSubstitute; using Xunit.Abstractions; namespace API.Tests.Helpers; +#nullable enable public class ScannerHelper { @@ -63,10 +64,10 @@ public class ScannerHelper return library; } - public ScannerService CreateServices() + public ScannerService CreateServices(DirectoryService ds = null, IFileSystem fs = null) { - var fs = new FileSystem(); - var ds = new DirectoryService(Substitute.For>(), fs); + fs ??= new FileSystem(); + ds ??= new DirectoryService(Substitute.For>(), fs); var archiveService = new ArchiveService(Substitute.For>(), ds, Substitute.For(), Substitute.For()); var readingItemService = new ReadingItemService(archiveService, Substitute.For(), @@ -133,7 +134,7 @@ public class ScannerHelper _testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}"); - return testDirectory; + return Path.GetFullPath(testDirectory); } diff --git a/API.Tests/Helpers/SeriesHelperTests.cs b/API.Tests/Helpers/SeriesHelperTests.cs index a5b5a063b..22b4a3cd1 100644 --- a/API.Tests/Helpers/SeriesHelperTests.cs +++ b/API.Tests/Helpers/SeriesHelperTests.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using API.Data; using API.Entities; using API.Entities.Enums; using API.Extensions; diff --git a/API.Tests/Helpers/StringHelperTests.cs b/API.Tests/Helpers/StringHelperTests.cs new file mode 100644 index 000000000..8f845c9b0 --- /dev/null +++ b/API.Tests/Helpers/StringHelperTests.cs @@ -0,0 +1,46 @@ +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class StringHelperTests +{ + [Theory] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!



Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

", + "

A Perfect Marriage Becomes a Perfect Affair!
Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

" + )] + [InlineData( + "

Blog | Twitter | Pixiv | Pawoo

", + "

Blog | Twitter | Pixiv | Pawoo

" + )] + public void TestSquashBreaklines(string input, string expected) + { + Assert.Equal(expected, StringHelper.SquashBreaklines(input)); + } + + [Theory] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!
(Source: Anime News Network)

", + "

A Perfect Marriage Becomes a Perfect Affair!

" + )] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!

(Source: Anime News Network)", + "

A Perfect Marriage Becomes a Perfect Affair!

" + )] + public void TestRemoveSourceInDescription(string input, string expected) + { + Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input)); + } + + + [Theory] + [InlineData( +"""Pawoo

""", +"""Pawoo

""" + )] + public void TestCorrectUrls(string input, string expected) + { + Assert.Equal(expected, StringHelper.CorrectUrls(input)); + } +} diff --git a/API.Tests/Parsers/BasicParserTests.cs b/API.Tests/Parsers/BasicParserTests.cs index ad040d59e..32673e0e6 100644 --- a/API.Tests/Parsers/BasicParserTests.cs +++ b/API.Tests/Parsers/BasicParserTests.cs @@ -1,4 +1,5 @@ -using System.IO.Abstractions.TestingHelpers; +using System.IO; +using System.IO.Abstractions.TestingHelpers; using API.Entities.Enums; using API.Services; using API.Services.Tasks.Scanner.Parser; @@ -8,59 +9,54 @@ using Xunit; namespace API.Tests.Parsers; -public class BasicParserTests +public class BasicParserTests : AbstractFsTest { private readonly BasicParser _parser; private readonly ILogger _dsLogger = Substitute.For>(); - private const string RootDirectory = "C:/Books/"; + private readonly string _rootDirectory; public BasicParserTests() { - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Books/"); - fileSystem.AddFile("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", new MockFileData("")); + var fileSystem = CreateFileSystem(); + _rootDirectory = Path.Join(DataDirectory, "Books/"); + fileSystem.AddDirectory(_rootDirectory); + fileSystem.AddFile($"{_rootDirectory}Harry Potter/Harry Potter - Vol 1.epub", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World - Chapter 3.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World Gaiden SP01.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Chapter 3.cbz", new MockFileData("")); + fileSystem.AddFile("$\"{RootDirectory}Accel World/Accel World Gaiden SP01.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/cover.png", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/cover.png", new MockFileData("")); - fileSystem.AddFile("C:/Books/Batman/Batman #1.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Batman/Batman #1.cbz", new MockFileData("")); var ds = new DirectoryService(_dsLogger, fileSystem); _parser = new BasicParser(ds, new ImageParser(ds)); } - #region Parse_Books - - - - #endregion - #region Parse_Manga /// - /// Tests that when there is a loose leaf cover in the manga library, that it is ignored + /// Tests that when there is a loose-leaf cover in the manga library, that it is ignored /// [Fact] public void Parse_MangaLibrary_JustCover_ShouldReturnNull() { - var actual = _parser.Parse(@"C:/Books/Accel World/cover.png", "C:/Books/Accel World/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Accel World/cover.png", $"{_rootDirectory}Accel World/", + _rootDirectory, LibraryType.Manga); Assert.Null(actual); } /// - /// Tests that when there is a loose leaf cover in the manga library, that it is ignored + /// Tests that when there is a loose-leaf cover in the manga library, that it is ignored /// [Fact] public void Parse_MangaLibrary_OtherImage_ShouldReturnNull() { - var actual = _parser.Parse(@"C:/Books/Accel World/page 01.png", "C:/Books/Accel World/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Accel World/page 01.png", $"{_rootDirectory}Accel World/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); } @@ -70,8 +66,8 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_VolumeAndChapterInFilename() { - var actual = _parser.Parse("C:/Books/Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", "C:/Books/Mujaki no Rakuen/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", $"{_rootDirectory}Mujaki no Rakuen/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Mujaki no Rakuen", actual.Series); @@ -86,9 +82,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_JustVolumeInFilename() { - var actual = _parser.Parse("C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz", - "C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz", + $"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", actual.Series); @@ -103,9 +99,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_JustChapterInFilename() { - var actual = _parser.Parse("C:/Books/Beelzebub/Beelzebub_01_[Noodles].zip", - "C:/Books/Beelzebub/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Beelzebub/Beelzebub_01_[Noodles].zip", + $"{_rootDirectory}Beelzebub/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Beelzebub", actual.Series); @@ -120,9 +116,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_SpecialMarkerInFilename() { - var actual = _parser.Parse("C:/Books/Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr", - "C:/Books/Summer Time Rendering/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr", + $"{_rootDirectory}Summer Time Rendering/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Summer Time Rendering", actual.Series); @@ -133,36 +129,54 @@ public class BasicParserTests /// - /// Tests that when the filename parses as a speical, it appropriately parses + /// Tests that when the filename parses as a special, it appropriately parses /// [Fact] public void Parse_MangaLibrary_SpecialInFilename() { - var actual = _parser.Parse("C:/Books/Summer Time Rendering/Volume SP01.cbr", - "C:/Books/Summer Time Rendering/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Volume SP01.cbr", + $"{_rootDirectory}Summer Time Rendering/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Summer Time Rendering", actual.Series); - Assert.Equal("Volume SP01", actual.Title); + Assert.Equal("Volume", actual.Title); Assert.Equal(Parser.SpecialVolume, actual.Volumes); Assert.Equal(Parser.DefaultChapter, actual.Chapters); Assert.True(actual.IsSpecial); } /// - /// Tests that when the filename parses as a speical, it appropriately parses + /// Tests that when the filename parses as a special, it appropriately parses /// [Fact] public void Parse_MangaLibrary_SpecialInFilename2() { var actual = _parser.Parse("M:/Kimi wa Midara na Boku no Joou/Specials/[Renzokusei] Special 1 SP02.zip", "M:/Kimi wa Midara na Boku no Joou/", - RootDirectory, LibraryType.Manga, null); + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Kimi wa Midara na Boku no Joou", actual.Series); - Assert.Equal("[Renzokusei] Special 1 SP02", actual.Title); + Assert.Equal("[Renzokusei] Special 1", actual.Title); + Assert.Equal(Parser.SpecialVolume, actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + /// + /// Tests that when the filename parses as a special, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_SpecialInFilename_StrangeNaming() + { + var actual = _parser.Parse($"{_rootDirectory}My Dress-Up Darling/SP01 1. Special Name.cbz", + _rootDirectory, + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("My Dress-Up Darling", actual.Series); + Assert.Equal("1. Special Name", actual.Title); Assert.Equal(Parser.SpecialVolume, actual.Volumes); Assert.Equal(Parser.DefaultChapter, actual.Chapters); Assert.True(actual.IsSpecial); @@ -174,9 +188,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_EditionInFilename() { - var actual = _parser.Parse("C:/Books/Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", - "C:/Books/Air Gear/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", + $"{_rootDirectory}Air Gear/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Air Gear", actual.Series); @@ -195,9 +209,9 @@ public class BasicParserTests [Fact] public void Parse_MangaBooks_JustVolumeInFilename() { - var actual = _parser.Parse("C:/Books/Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", - "C:/Books/Epubs/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", + $"{_rootDirectory}Epubs/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Harrison, Kim - The Good, The Bad, and the Undead - Hollows", actual.Series); diff --git a/API.Tests/Parsers/BookParserTests.cs b/API.Tests/Parsers/BookParserTests.cs index 6be0fe386..90147ac6b 100644 --- a/API.Tests/Parsers/BookParserTests.cs +++ b/API.Tests/Parsers/BookParserTests.cs @@ -1,5 +1,4 @@ using System.IO.Abstractions.TestingHelpers; -using API.Data.Metadata; using API.Entities.Enums; using API.Services; using API.Services.Tasks.Scanner.Parser; diff --git a/API.Tests/Parsers/ComicVineParserTests.cs b/API.Tests/Parsers/ComicVineParserTests.cs index f01e98afd..2f4fd568e 100644 --- a/API.Tests/Parsers/ComicVineParserTests.cs +++ b/API.Tests/Parsers/ComicVineParserTests.cs @@ -36,7 +36,7 @@ public class ComicVineParserTests public void Parse_SeriesWithComicInfo() { var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", - RootDirectory, LibraryType.ComicVine, new ComicInfo() + RootDirectory, LibraryType.ComicVine, true, new ComicInfo() { Series = "Birds of Prey", Volume = "2002" @@ -54,7 +54,7 @@ public class ComicVineParserTests public void Parse_SeriesWithDirectoryNameAsSeriesYear() { var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey (2002)", actual.Series); @@ -69,7 +69,7 @@ public class ComicVineParserTests public void Parse_SeriesWithADirectoryNameAsSeriesYear() { var actual = _parser.Parse("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", "C:/Comics/DC Comics/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey (1999)", actual.Series); @@ -84,7 +84,7 @@ public class ComicVineParserTests public void Parse_FallbackToDirectoryNameOnly() { var actual = _parser.Parse("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", "C:/Comics/DC Comics/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Blood Syndicate", actual.Series); diff --git a/API.Tests/Parsers/DefaultParserTests.cs b/API.Tests/Parsers/DefaultParserTests.cs index 733b55d62..244c08b97 100644 --- a/API.Tests/Parsers/DefaultParserTests.cs +++ b/API.Tests/Parsers/DefaultParserTests.cs @@ -33,7 +33,7 @@ public class DefaultParserTests [InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")] public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries) { - var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, null); + var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, true, null); if (actual == null) { Assert.NotNull(actual); @@ -74,7 +74,7 @@ public class DefaultParserTests fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); var parser = new BasicParser(ds, new ImageParser(ds)); - var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -90,7 +90,7 @@ public class DefaultParserTests fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); var parser = new BasicParser(ds, new ImageParser(ds)); - var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -251,7 +251,7 @@ public class DefaultParserTests foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, null); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, true, null); if (expectedInfo == null) { Assert.Null(actual); @@ -289,7 +289,7 @@ public class DefaultParserTests Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; - var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null); + var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -315,7 +315,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -341,7 +341,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -383,7 +383,7 @@ public class DefaultParserTests FullFilePath = filepath }; - var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); + var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); @@ -412,7 +412,7 @@ public class DefaultParserTests FullFilePath = filepath }; - actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); + actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expected.Format, actual.Format); @@ -475,7 +475,7 @@ public class DefaultParserTests foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, null); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, true, null); if (expectedInfo == null) { Assert.Null(actual); diff --git a/API.Tests/Parsers/ImageParserTests.cs b/API.Tests/Parsers/ImageParserTests.cs index f95c98ddf..63df1926e 100644 --- a/API.Tests/Parsers/ImageParserTests.cs +++ b/API.Tests/Parsers/ImageParserTests.cs @@ -34,7 +34,7 @@ public class ImageParserTests public void Parse_SeriesWithDirectoryName() { var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01/01.jpg", "C:/Comics/Birds of Prey/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); @@ -48,7 +48,7 @@ public class ImageParserTests public void Parse_SeriesWithNoNestedChapter() { var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01 page 01.jpg", "C:/Comics/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); @@ -62,7 +62,7 @@ public class ImageParserTests public void Parse_SeriesWithLooseImages() { var actual = _parser.Parse("C:/Comics/Birds of Prey/page 01.jpg", "C:/Comics/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); diff --git a/API.Tests/Parsers/PdfParserTests.cs b/API.Tests/Parsers/PdfParserTests.cs index 72088526d..08bf9f25d 100644 --- a/API.Tests/Parsers/PdfParserTests.cs +++ b/API.Tests/Parsers/PdfParserTests.cs @@ -35,7 +35,7 @@ public class PdfParserTests { var actual = _parser.Parse("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf", "C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/", - RootDirectory, LibraryType.Book, null); + RootDirectory, LibraryType.Book, true, null); Assert.NotNull(actual); Assert.Equal("A Dictionary of Japanese Food - Ingredients and Culture", actual.Series); diff --git a/API.Tests/Parsing/ImageParsingTests.cs b/API.Tests/Parsing/ImageParsingTests.cs index 3d78d9372..362b4b08c 100644 --- a/API.Tests/Parsing/ImageParsingTests.cs +++ b/API.Tests/Parsing/ImageParsingTests.cs @@ -34,7 +34,7 @@ public class ImageParsingTests Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; - var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, null); + var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -60,7 +60,7 @@ public class ImageParsingTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -86,7 +86,7 @@ public class ImageParsingTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index a975cc7ee..53f2bc4c9 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -1,18 +1,10 @@ using API.Entities.Enums; using Xunit; -using Xunit.Abstractions; namespace API.Tests.Parsing; public class MangaParsingTests { - private readonly ITestOutputHelper _testOutputHelper; - - public MangaParsingTests(ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - } - [Theory] [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "1")] @@ -76,7 +68,6 @@ public class MangaParsingTests [InlineData("Манга Тома 1-4", "1-4")] [InlineData("Манга Том 1-4", "1-4")] [InlineData("조선왕조실톡 106화", "106")] - [InlineData("죽음 13회", "13")] [InlineData("동의보감 13장", "13")] [InlineData("몰?루 아카이브 7.5권", "7.5")] [InlineData("63권#200", "63")] @@ -84,6 +75,7 @@ public class MangaParsingTests [InlineData("Accel World Chapter 001 Volume 002", "2")] [InlineData("Accel World Volume 2", "2")] [InlineData("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake", "30")] + [InlineData("Zom 100 - Bucket List of the Dead v01", "1")] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename, LibraryType.Manga)); @@ -212,6 +204,8 @@ public class MangaParsingTests [InlineData("不安の種\uff0b - 01", "不安の種\uff0b")] [InlineData("Giant Ojou-sama - Ch. 33.5 - Volume 04 Bonus Chapter", "Giant Ojou-sama")] [InlineData("[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE", "")] + [InlineData("Monster #8 Ch. 001", "Monster #8")] + [InlineData("Zom 100 - Bucket List of the Dead v01", "Zom 100 - Bucket List of the Dead")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga)); @@ -304,6 +298,7 @@ public class MangaParsingTests [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")] [InlineData("Max Level Returner ตอนที่ 5", "5")] [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")] + [InlineData("Monster #8 Ch. 001", "1")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename, LibraryType.Manga)); diff --git a/API.Tests/Parsing/ParserInfoTests.cs b/API.Tests/Parsing/ParserInfoTests.cs index 61ae8ecf2..cbb8ae99a 100644 --- a/API.Tests/Parsing/ParserInfoTests.cs +++ b/API.Tests/Parsing/ParserInfoTests.cs @@ -11,14 +11,14 @@ public class ParserInfoTests { var p1 = new ParserInfo() { - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + Chapters = Parser.DefaultChapter, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", IsSpecial = false, Series = "darker than black", Title = "darker than black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; var p2 = new ParserInfo() @@ -30,7 +30,7 @@ public class ParserInfoTests IsSpecial = false, Series = "darker than black", Title = "Darker Than Black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; var expected = new ParserInfo() @@ -42,7 +42,7 @@ public class ParserInfoTests IsSpecial = false, Series = "darker than black", Title = "darker than black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; p1.Merge(p2); @@ -62,12 +62,12 @@ public class ParserInfoTests IsSpecial = true, Series = "darker than black", Title = "darker than black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; var p2 = new ParserInfo() { - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + Chapters = Parser.DefaultChapter, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", diff --git a/API.Tests/Parsing/ParsingTests.cs b/API.Tests/Parsing/ParsingTests.cs index a3a49762d..7d5da4f9c 100644 --- a/API.Tests/Parsing/ParsingTests.cs +++ b/API.Tests/Parsing/ParsingTests.cs @@ -1,6 +1,5 @@ using System.Globalization; using System.Linq; -using System.Runtime.InteropServices; using Xunit; using static API.Services.Tasks.Scanner.Parser.Parser; @@ -11,9 +10,13 @@ public class ParsingTests [Fact] public void ShouldWork() { - var s = 6.5f + ""; + var s = 6.5f.ToString(CultureInfo.InvariantCulture); var a = float.Parse(s, CultureInfo.InvariantCulture); Assert.Equal(6.5f, a); + + s = 6.5f + ""; + a = float.Parse(s, CultureInfo.CurrentCulture); + Assert.Equal(6.5f, a); } // [Theory] @@ -40,6 +43,7 @@ public class ParsingTests [InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")] [InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")] [InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")] + [InlineData("SP01 1. DEAD Tube Prologue", "1. DEAD Tube Prologue")] public void CleanSpecialTitleTest(string input, string expected) { Assert.Equal(expected, CleanSpecialTitle(input)); @@ -247,6 +251,7 @@ public class ParsingTests [InlineData("ch1/backcover.png", false)] [InlineData("backcover.png", false)] [InlineData("back_cover.png", false)] + [InlineData("LD Blacklands #1 35 (back cover).png", false)] public void IsCoverImageTest(string inputPath, bool expected) { Assert.Equal(expected, IsCoverImage(inputPath)); diff --git a/API.Tests/Repository/CollectionTagRepositoryTests.cs b/API.Tests/Repository/CollectionTagRepositoryTests.cs index 6abf3f7e7..5318260be 100644 --- a/API.Tests/Repository/CollectionTagRepositoryTests.cs +++ b/API.Tests/Repository/CollectionTagRepositoryTests.cs @@ -15,7 +15,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; using NSubstitute; -using Xunit; namespace API.Tests.Repository; diff --git a/API.Tests/Repository/GenreRepositoryTests.cs b/API.Tests/Repository/GenreRepositoryTests.cs new file mode 100644 index 000000000..d197a91ba --- /dev/null +++ b/API.Tests/Repository/GenreRepositoryTests.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class GenreRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Genre.RemoveRange(Context.Genre); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + private TestGenreSet CreateTestGenres() + { + return new TestGenreSet + { + SharedSeriesChaptersGenre = new GenreBuilder("Shared Series Chapter Genre").Build(), + SharedSeriesGenre = new GenreBuilder("Shared Series Genre").Build(), + SharedChaptersGenre = new GenreBuilder("Shared Chapters Genre").Build(), + Lib0SeriesChaptersGenre = new GenreBuilder("Lib0 Series Chapter Genre").Build(), + Lib0SeriesGenre = new GenreBuilder("Lib0 Series Genre").Build(), + Lib0ChaptersGenre = new GenreBuilder("Lib0 Chapters Genre").Build(), + Lib1SeriesChaptersGenre = new GenreBuilder("Lib1 Series Chapter Genre").Build(), + Lib1SeriesGenre = new GenreBuilder("Lib1 Series Genre").Build(), + Lib1ChaptersGenre = new GenreBuilder("Lib1 Chapters Genre").Build(), + Lib1ChapterAgeGenre = new GenreBuilder("Lib1 Chapter Age Genre").Build() + }; + } + + private async Task SeedDbWithGenres(TestGenreSet genres) + { + await CreateTestUsers(); + await AddGenresToContext(genres); + await CreateLibrariesWithGenres(genres); + await AssignLibrariesToUsers(); + } + + private async Task CreateTestUsers() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.Users.Add(_fullAccess); + Context.Users.Add(_restrictedAccess); + Context.Users.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + } + + private async Task AddGenresToContext(TestGenreSet genres) + { + var allGenres = genres.GetAllGenres(); + Context.Genre.AddRange(allGenres); + await Context.SaveChangesAsync(); + } + + private async Task CreateLibrariesWithGenres(TestGenreSet genres) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0SeriesGenre]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre, genres.Lib1ChapterAgeGenre]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .Build()) + .Build()) + .Build(); + + Context.Library.Add(lib0); + Context.Library.Add(lib1); + await Context.SaveChangesAsync(); + } + + private async Task AssignLibrariesToUsers() + { + var lib0 = Context.Library.First(l => l.Name == "lib0"); + var lib1 = Context.Library.First(l => l.Name == "lib1"); + + _fullAccess.Libraries.Add(lib0); + _fullAccess.Libraries.Add(lib1); + _restrictedAccess.Libraries.Add(lib1); + _restrictedAgeAccess.Libraries.Add(lib1); + + await Context.SaveChangesAsync(); + } + + private static Predicate ContainsGenreCheck(Genre genre) + { + return g => g.Id == genre.Id; + } + + private static void AssertGenrePresent(IEnumerable genres, Genre expectedGenre) + { + Assert.Contains(genres, ContainsGenreCheck(expectedGenre)); + } + + private static void AssertGenreNotPresent(IEnumerable genres, Genre expectedGenre) + { + Assert.DoesNotContain(genres, ContainsGenreCheck(expectedGenre)); + } + + private static BrowseGenreDto GetGenreDto(IEnumerable genres, Genre genre) + { + return genres.First(dto => dto.Id == genre.Id); + } + + [Fact] + public async Task GetBrowseableGenre_FullAccess_ReturnsAllGenresWithCorrectCounts() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var fullAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_fullAccess.Id, new UserParams()); + + // Assert + Assert.Equal(genres.GetAllGenres().Count, fullAccessGenres.TotalCount); + + foreach (var genre in genres.GetAllGenres()) + { + AssertGenrePresent(fullAccessGenres, genre); + } + + // Verify counts - 1 lib0 series, 2 lib1 series = 3 total series + Assert.Equal(3, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(6, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(1, GetGenreDto(fullAccessGenres, genres.Lib0SeriesGenre).SeriesCount); + } + + [Fact] + public async Task GetBrowseableGenre_RestrictedAccess_ReturnsOnlyAccessibleGenres() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var restrictedAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 4 library 1 specific = 7 genres + Assert.Equal(7, restrictedAccessGenres.TotalCount); + + // Verify shared and Library 1 genres are present + AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesGenre); + AssertGenrePresent(restrictedAccessGenres, genres.SharedChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChapterAgeGenre); + + // Verify Library 0 specific genres are not present + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesChaptersGenre); + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesGenre); + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0ChaptersGenre); + + // Verify counts - 2 lib1 series + Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.Lib1SeriesGenre).SeriesCount); + Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.Lib1ChaptersGenre).ChapterCount); + Assert.Equal(1, GetGenreDto(restrictedAccessGenres, genres.Lib1ChapterAgeGenre).ChapterCount); + } + + [Fact] + public async Task GetBrowseableGenre_RestrictedAgeAccess_FiltersAgeRestrictedContent() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var restrictedAgeAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAgeAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 3 lib1 specific = 6 genres (age-restricted genre filtered out) + Assert.Equal(6, restrictedAgeAccessGenres.TotalCount); + + // Verify accessible genres are present + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre); + + // Verify age-restricted genre is filtered out + AssertGenreNotPresent(restrictedAgeAccessGenres, genres.Lib1ChapterAgeGenre); + + // Verify counts - 1 series lib1 (age-restricted series filtered out) + Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1SeriesGenre).SeriesCount); + + // These values represent a bug - chapters are not properly filtered when their series is age-restricted + // Should be 2, but currently returns 3 due to the filtering issue + Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre).ChapterCount); + } + + private class TestGenreSet + { + public Genre SharedSeriesChaptersGenre { get; set; } + public Genre SharedSeriesGenre { get; set; } + public Genre SharedChaptersGenre { get; set; } + public Genre Lib0SeriesChaptersGenre { get; set; } + public Genre Lib0SeriesGenre { get; set; } + public Genre Lib0ChaptersGenre { get; set; } + public Genre Lib1SeriesChaptersGenre { get; set; } + public Genre Lib1SeriesGenre { get; set; } + public Genre Lib1ChaptersGenre { get; set; } + public Genre Lib1ChapterAgeGenre { get; set; } + + public List GetAllGenres() + { + return + [ + SharedSeriesChaptersGenre, SharedSeriesGenre, SharedChaptersGenre, + Lib0SeriesChaptersGenre, Lib0SeriesGenre, Lib0ChaptersGenre, + Lib1SeriesChaptersGenre, Lib1SeriesGenre, Lib1ChaptersGenre, Lib1ChapterAgeGenre + ]; + } + } +} diff --git a/API.Tests/Repository/PersonRepositoryTests.cs b/API.Tests/Repository/PersonRepositoryTests.cs new file mode 100644 index 000000000..a2b19cc0c --- /dev/null +++ b/API.Tests/Repository/PersonRepositoryTests.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class PersonRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + Context.AppUser.RemoveRange(Context.AppUser.ToList()); + await UnitOfWork.CommitAsync(); + } + + private async Task SeedDb() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.AppUser.Add(_fullAccess); + Context.AppUser.Add(_restrictedAccess); + Context.AppUser.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + + var people = CreateTestPeople(); + Context.Person.AddRange(people); + await Context.SaveChangesAsync(); + + var libraries = CreateTestLibraries(people); + Context.Library.AddRange(libraries); + await Context.SaveChangesAsync(); + + _fullAccess.Libraries.Add(libraries[0]); // lib0 + _fullAccess.Libraries.Add(libraries[1]); // lib1 + _restrictedAccess.Libraries.Add(libraries[1]); // lib1 only + _restrictedAgeAccess.Libraries.Add(libraries[1]); // lib1 only + + await Context.SaveChangesAsync(); + } + + private static List CreateTestPeople() + { + return new List + { + new PersonBuilder("Shared Series Chapter Person").Build(), + new PersonBuilder("Shared Series Person").Build(), + new PersonBuilder("Shared Chapters Person").Build(), + new PersonBuilder("Lib0 Series Chapter Person").Build(), + new PersonBuilder("Lib0 Series Person").Build(), + new PersonBuilder("Lib0 Chapters Person").Build(), + new PersonBuilder("Lib1 Series Chapter Person").Build(), + new PersonBuilder("Lib1 Series Person").Build(), + new PersonBuilder("Lib1 Chapters Person").Build(), + new PersonBuilder("Lib1 Chapter Age Person").Build() + }; + } + + private static List CreateTestLibraries(List people) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Lib0 Series Person"), PersonRole.Writer) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Colorist) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Editor) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Letterer) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Imprint) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Chapter Age Person"), PersonRole.CoverArtist) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Inker) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Team) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Translator) + .Build()) + .Build()) + .Build()) + .Build(); + + return new List { lib0, lib1 }; + } + + private static Person GetPersonByName(List people, string name) + { + return people.First(p => p.Name == name); + } + + private Person GetPersonByName(string name) + { + return Context.Person.First(p => p.Name == name); + } + + private static Predicate ContainsPersonCheck(Person person) + { + return p => p.Id == person.Id; + } + + [Fact] + public async Task GetBrowsePersonDtos() + { + await ResetDb(); + await SeedDb(); + + // Get people from database for assertions + var sharedSeriesChaptersPerson = GetPersonByName("Shared Series Chapter Person"); + var lib0SeriesPerson = GetPersonByName("Lib0 Series Person"); + var lib1SeriesPerson = GetPersonByName("Lib1 Series Person"); + var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person"); + var allPeople = Context.Person.ToList(); + + var fullAccessPeople = + await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_fullAccess.Id, new BrowsePersonFilterDto(), + new UserParams()); + Assert.Equal(allPeople.Count, fullAccessPeople.TotalCount); + + foreach (var person in allPeople) + Assert.Contains(fullAccessPeople, ContainsPersonCheck(person)); + + // 1 series in lib0, 2 series in lib1 + Assert.Equal(3, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount); + // 3 series with each 2 chapters + Assert.Equal(6, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount); + // 1 series in lib0 + Assert.Equal(1, fullAccessPeople.First(dto => dto.Id == lib0SeriesPerson.Id).SeriesCount); + // 2 series in lib1 + Assert.Equal(2, fullAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount); + + var restrictedAccessPeople = + await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAccess.Id, new BrowsePersonFilterDto(), + new UserParams()); + + Assert.Equal(7, restrictedAccessPeople.TotalCount); + + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Chapter Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Chapters Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Chapter Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapters Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapter Age Person"))); + + // 2 series in lib1, no series in lib0 + Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount); + // 2 series with each 2 chapters + Assert.Equal(4, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount); + // 2 series in lib1 + Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount); + + var restrictedAgeAccessPeople = await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAgeAccess.Id, + new BrowsePersonFilterDto(), new UserParams()); + + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Equal(6, restrictedAgeAccessPeople.TotalCount); + + // No access to the age restricted chapter + Assert.DoesNotContain(restrictedAgeAccessPeople, ContainsPersonCheck(lib1ChapterAgePerson)); + } + + [Fact] + public async Task GetRolesForPersonByName() + { + await ResetDb(); + await SeedDb(); + + var sharedSeriesPerson = GetPersonByName("Shared Series Person"); + var sharedChaptersPerson = GetPersonByName("Shared Chapters Person"); + var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person"); + + var sharedSeriesRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _fullAccess.Id); + var chapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _fullAccess.Id); + var ageChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _fullAccess.Id); + Assert.Equal(3, sharedSeriesRoles.Count()); + Assert.Equal(6, chapterRoles.Count()); + Assert.Single(ageChapterRoles); + + var restrictedRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAccess.Id); + var restrictedChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAccess.Id); + var restrictedAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAccess.Id); + Assert.Equal(2, restrictedRoles.Count()); + Assert.Equal(4, restrictedChapterRoles.Count()); + Assert.Single(restrictedAgePersonChapterRoles); + + var restrictedAgeRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAgeAccess.Id); + var restrictedAgeChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAgeAccess.Id); + var restrictedAgeAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAgeAccess.Id); + Assert.Single(restrictedAgeRoles); + Assert.Equal(2, restrictedAgeChapterRoles.Count()); + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Empty(restrictedAgeAgePersonChapterRoles); + } + + [Fact] + public async Task GetPersonDtoByName() + { + await ResetDb(); + await SeedDb(); + + var allPeople = Context.Person.ToList(); + + foreach (var person in allPeople) + { + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName(person.Name, _fullAccess.Id)); + } + + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Shared Series Person", _restrictedAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAccess.Id)); + + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAgeAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAgeAccess.Id)); + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Chapter Age Person", _restrictedAgeAccess.Id)); + } + + [Fact] + public async Task GetSeriesKnownFor() + { + await ResetDb(); + await SeedDb(); + + var sharedSeriesPerson = GetPersonByName("Shared Series Person"); + var lib1SeriesPerson = GetPersonByName("Lib1 Series Person"); + + var series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _fullAccess.Id); + Assert.Equal(3, series.Count()); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAccess.Id); + Assert.Equal(2, series.Count()); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAgeAccess.Id); + Assert.Single(series); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(lib1SeriesPerson.Id, _restrictedAgeAccess.Id); + Assert.Single(series); + } + + [Fact] + public async Task GetChaptersForPersonByRole() + { + await ResetDb(); + await SeedDb(); + + var sharedChaptersPerson = GetPersonByName("Shared Chapters Person"); + + // Lib0 + var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Colorist); + var restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Colorist); + var restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Colorist); + Assert.Single(chapters); + Assert.Empty(restrictedChapters); + Assert.Empty(restrictedAgeChapters); + + // Lib1 - age restricted series + chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Imprint); + restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Imprint); + restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Imprint); + Assert.Single(chapters); + Assert.Single(restrictedChapters); + Assert.Empty(restrictedAgeChapters); + + // Lib1 - not age restricted series + chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Team); + restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Team); + restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Team); + Assert.Single(chapters); + Assert.Single(restrictedChapters); + Assert.Single(restrictedAgeChapters); + } +} diff --git a/API.Tests/Repository/SeriesRepositoryTests.cs b/API.Tests/Repository/SeriesRepositoryTests.cs index 73ed58a5a..5705e1bc0 100644 --- a/API.Tests/Repository/SeriesRepositoryTests.cs +++ b/API.Tests/Repository/SeriesRepositoryTests.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using API.Data; using API.Entities; using API.Entities.Enums; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; diff --git a/API.Tests/Repository/TagRepositoryTests.cs b/API.Tests/Repository/TagRepositoryTests.cs new file mode 100644 index 000000000..229082eb6 --- /dev/null +++ b/API.Tests/Repository/TagRepositoryTests.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class TagRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Tag.RemoveRange(Context.Tag); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + private TestTagSet CreateTestTags() + { + return new TestTagSet + { + SharedSeriesChaptersTag = new TagBuilder("Shared Series Chapter Tag").Build(), + SharedSeriesTag = new TagBuilder("Shared Series Tag").Build(), + SharedChaptersTag = new TagBuilder("Shared Chapters Tag").Build(), + Lib0SeriesChaptersTag = new TagBuilder("Lib0 Series Chapter Tag").Build(), + Lib0SeriesTag = new TagBuilder("Lib0 Series Tag").Build(), + Lib0ChaptersTag = new TagBuilder("Lib0 Chapters Tag").Build(), + Lib1SeriesChaptersTag = new TagBuilder("Lib1 Series Chapter Tag").Build(), + Lib1SeriesTag = new TagBuilder("Lib1 Series Tag").Build(), + Lib1ChaptersTag = new TagBuilder("Lib1 Chapters Tag").Build(), + Lib1ChapterAgeTag = new TagBuilder("Lib1 Chapter Age Tag").Build() + }; + } + + private async Task SeedDbWithTags(TestTagSet tags) + { + await CreateTestUsers(); + await AddTagsToContext(tags); + await CreateLibrariesWithTags(tags); + await AssignLibrariesToUsers(); + } + + private async Task CreateTestUsers() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.Users.Add(_fullAccess); + Context.Users.Add(_restrictedAccess); + Context.Users.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + } + + private async Task AddTagsToContext(TestTagSet tags) + { + var allTags = tags.GetAllTags(); + Context.Tag.AddRange(allTags); + await Context.SaveChangesAsync(); + } + + private async Task CreateLibrariesWithTags(TestTagSet tags) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadata + { + Tags = [tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib0SeriesChaptersTag, tags.Lib0SeriesTag] + }) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib0SeriesChaptersTag, tags.Lib0ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag, tags.Lib1ChapterAgeTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .Build(); + + Context.Library.Add(lib0); + Context.Library.Add(lib1); + await Context.SaveChangesAsync(); + } + + private async Task AssignLibrariesToUsers() + { + var lib0 = Context.Library.First(l => l.Name == "lib0"); + var lib1 = Context.Library.First(l => l.Name == "lib1"); + + _fullAccess.Libraries.Add(lib0); + _fullAccess.Libraries.Add(lib1); + _restrictedAccess.Libraries.Add(lib1); + _restrictedAgeAccess.Libraries.Add(lib1); + + await Context.SaveChangesAsync(); + } + + private static Predicate ContainsTagCheck(Tag tag) + { + return t => t.Id == tag.Id; + } + + private static void AssertTagPresent(IEnumerable tags, Tag expectedTag) + { + Assert.Contains(tags, ContainsTagCheck(expectedTag)); + } + + private static void AssertTagNotPresent(IEnumerable tags, Tag expectedTag) + { + Assert.DoesNotContain(tags, ContainsTagCheck(expectedTag)); + } + + private static BrowseTagDto GetTagDto(IEnumerable tags, Tag tag) + { + return tags.First(dto => dto.Id == tag.Id); + } + + [Fact] + public async Task GetBrowseableTag_FullAccess_ReturnsAllTagsWithCorrectCounts() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var fullAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_fullAccess.Id, new UserParams()); + + // Assert + Assert.Equal(tags.GetAllTags().Count, fullAccessTags.TotalCount); + + foreach (var tag in tags.GetAllTags()) + { + AssertTagPresent(fullAccessTags, tag); + } + + // Verify counts - 1 series lib0, 2 series lib1 = 3 total series + Assert.Equal(3, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(6, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(1, GetTagDto(fullAccessTags, tags.Lib0SeriesTag).SeriesCount); + } + + [Fact] + public async Task GetBrowseableTag_RestrictedAccess_ReturnsOnlyAccessibleTags() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var restrictedAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 4 library 1 specific = 7 tags + Assert.Equal(7, restrictedAccessTags.TotalCount); + + // Verify shared and Library 1 tags are present + AssertTagPresent(restrictedAccessTags, tags.SharedSeriesChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.SharedSeriesTag); + AssertTagPresent(restrictedAccessTags, tags.SharedChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1ChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1ChapterAgeTag); + + // Verify Library 0 specific tags are not present + AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesChaptersTag); + AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesTag); + AssertTagNotPresent(restrictedAccessTags, tags.Lib0ChaptersTag); + + // Verify counts - 2 series lib1 + Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.Lib1SeriesTag).SeriesCount); + Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.Lib1ChaptersTag).ChapterCount); + } + + [Fact] + public async Task GetBrowseableTag_RestrictedAgeAccess_FiltersAgeRestrictedContent() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var restrictedAgeAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAgeAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 3 lib1 specific = 6 tags (age-restricted tag filtered out) + Assert.Equal(6, restrictedAgeAccessTags.TotalCount); + + // Verify accessible tags are present + AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesTag); + AssertTagPresent(restrictedAgeAccessTags, tags.SharedChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1ChaptersTag); + + // Verify age-restricted tag is filtered out + AssertTagNotPresent(restrictedAgeAccessTags, tags.Lib1ChapterAgeTag); + + // Verify counts - 1 series lib1 (age-restricted series filtered out) + Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.Lib1SeriesTag).SeriesCount); + Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.Lib1ChaptersTag).ChapterCount); + } + + private class TestTagSet + { + public Tag SharedSeriesChaptersTag { get; set; } + public Tag SharedSeriesTag { get; set; } + public Tag SharedChaptersTag { get; set; } + public Tag Lib0SeriesChaptersTag { get; set; } + public Tag Lib0SeriesTag { get; set; } + public Tag Lib0ChaptersTag { get; set; } + public Tag Lib1SeriesChaptersTag { get; set; } + public Tag Lib1SeriesTag { get; set; } + public Tag Lib1ChaptersTag { get; set; } + public Tag Lib1ChapterAgeTag { get; set; } + + public List GetAllTags() + { + return + [ + SharedSeriesChaptersTag, SharedSeriesTag, SharedChaptersTag, + Lib0SeriesChaptersTag, Lib0SeriesTag, Lib0ChaptersTag, + Lib1SeriesChaptersTag, Lib1SeriesTag, Lib1ChaptersTag, Lib1ChapterAgeTag + ]; + } + } +} diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 260676843..8cf93df37 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -7,7 +7,6 @@ using System.Linq; using API.Archive; using API.Entities.Enums; using API.Services; -using EasyCaching.Core; using Microsoft.Extensions.Logging; using NetVips; using NSubstitute; diff --git a/API.Tests/Services/BackupServiceTests.cs b/API.Tests/Services/BackupServiceTests.cs index c4ca95a11..aac5724f7 100644 --- a/API.Tests/Services/BackupServiceTests.cs +++ b/API.Tests/Services/BackupServiceTests.cs @@ -1,10 +1,8 @@ -using System.Collections.Generic; -using System.Data.Common; +using System.Data.Common; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; -using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; @@ -21,7 +19,7 @@ using Xunit; namespace API.Tests.Services; -public class BackupServiceTests +public class BackupServiceTests: AbstractFsTest { private readonly ILogger _logger = Substitute.For>(); private readonly IUnitOfWork _unitOfWork; @@ -31,13 +29,6 @@ public class BackupServiceTests private readonly DbConnection _connection; private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string LogDirectory = "C:/kavita/config/logs/"; - private const string ConfigDirectory = "C:/kavita/config/"; - private const string BookmarkDirectory = "C:/kavita/config/bookmarks"; - private const string ThemesDirectory = "C:/kavita/config/theme"; public BackupServiceTests() { @@ -82,7 +73,7 @@ public class BackupServiceTests _context.ServerSetting.Update(setting); _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); return await _context.SaveChangesAsync() > 0; } @@ -94,22 +85,6 @@ public class BackupServiceTests await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(LogDirectory); - fileSystem.AddDirectory(ThemesDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory("C:/data/"); - - return fileSystem; - } - #endregion diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index 23716e2f7..5848c74ba 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -1,7 +1,8 @@ using System.IO; using System.IO.Abstractions; +using API.Entities.Enums; using API.Services; -using EasyCaching.Core; +using API.Services.Tasks.Scanner.Parser; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -81,4 +82,64 @@ public class BookServiceTests Assert.Equal("Accel World", comicInfo.Series); } + [Fact] + public void ShouldHaveComicInfoForPdf() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "test.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal("Variations Chromatiques de concert", comicInfo.Title); + Assert.Equal("Georges Bizet \\(1838-1875\\)", comicInfo.Writer); + } + + //[Fact] + public void ShouldUsePdfInfoDict() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Library/Books/PDFs"); + var document = Path.Join(testDirectory, "Rollo at Work SP01.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal("Rollo at Work", comicInfo.Title); + Assert.Equal("Jacob Abbott", comicInfo.Writer); + Assert.Equal(2008, comicInfo.Year); + } + + [Fact] + public void ShouldHandleIndirectPdfObjects() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "indirect.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal(2018, comicInfo.Year); + Assert.Equal(8, comicInfo.Month); + } + + [Fact] + public void FailGracefullyWithEncryptedPdf() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "encrypted.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.Null(comicInfo); + } + + [Fact] + public void SeriesFallBackToMetadataTitle() + { + var ds = new DirectoryService(Substitute.For>(), new FileSystem()); + var pdfParser = new PdfParser(ds); + + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var filePath = Path.Join(testDirectory, "Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf"); + + var comicInfo = _bookService.GetComicInfo(filePath); + Assert.NotNull(comicInfo); + + var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, true, comicInfo); + Assert.NotNull(parserInfo); + Assert.Equal(parserInfo.Title, comicInfo.Title); + Assert.Equal(parserInfo.Series, comicInfo.Title); + } } diff --git a/API.Tests/Services/BookmarkServiceTests.cs b/API.Tests/Services/BookmarkServiceTests.cs index 80a483833..596fbbc4d 100644 --- a/API.Tests/Services/BookmarkServiceTests.cs +++ b/API.Tests/Services/BookmarkServiceTests.cs @@ -9,12 +9,9 @@ using API.Data.Repositories; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; -using API.SignalR; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -25,17 +22,12 @@ using Xunit; namespace API.Tests.Services; -public class BookmarkServiceTests +public class BookmarkServiceTests: AbstractFsTest { private readonly IUnitOfWork _unitOfWork; private readonly DbConnection _connection; private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; - public BookmarkServiceTests() { @@ -88,7 +80,7 @@ Substitute.For()); _context.ServerSetting.Update(setting); _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); return await _context.SaveChangesAsync() > 0; } @@ -102,20 +94,6 @@ Substitute.For()); await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory("C:/data/"); - - return fileSystem; - } - #endregion #region BookmarkPage diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index ba06525a3..caf1ae393 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -1,12 +1,10 @@ -using System.Collections.Generic; -using System.Data.Common; +using System.Data.Common; using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Metadata; -using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; @@ -52,17 +50,17 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService throw new System.NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true) { throw new System.NotImplementedException(); } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true) { throw new System.NotImplementedException(); } } -public class CacheServiceTests +public class CacheServiceTests: AbstractFsTest { private readonly ILogger _logger = Substitute.For>(); private readonly IUnitOfWork _unitOfWork; @@ -71,11 +69,6 @@ public class CacheServiceTests private readonly DbConnection _connection; private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string DataDirectory = "C:/data/"; - public CacheServiceTests() { var contextOptions = new DbContextOptionsBuilder() @@ -118,7 +111,7 @@ public class CacheServiceTests _context.ServerSetting.Update(setting); _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); return await _context.SaveChangesAsync() > 0; } @@ -130,19 +123,6 @@ public class CacheServiceTests await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(DataDirectory); - - return fileSystem; - } - #endregion #region Ensure @@ -263,7 +243,7 @@ public class CacheServiceTests .WithFile(new MangaFileBuilder($"{DataDirectory}2.epub", MangaFormat.Epub).Build()) .Build(); cs.GetCachedFile(c); - Assert.Same($"{DataDirectory}1.epub", cs.GetCachedFile(c)); + Assert.Equal($"{DataDirectory}1.epub", cs.GetCachedFile(c)); } #endregion diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 2ebee8d1d..b0610aed5 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -1,16 +1,13 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; -using API.Data; using API.Data.Repositories; using API.DTOs.Filtering; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; using API.Extensions; using API.Helpers; using API.Helpers.Builders; @@ -30,14 +27,13 @@ public class CleanupServiceTests : AbstractDbTest private readonly IEventHub _messageHub = Substitute.For(); private readonly IReaderService _readerService; - public CleanupServiceTests() : base() { - _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + Context.Library.Add(new LibraryBuilder("Manga") + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); - _readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For(), + _readerService = new ReaderService(UnitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For()); } @@ -47,11 +43,11 @@ public class CleanupServiceTests : AbstractDbTest protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); - _context.Users.RemoveRange(_context.Users.ToList()); - _context.AppUserBookmark.RemoveRange(_context.AppUserBookmark.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); + Context.Users.RemoveRange(Context.Users.ToList()); + Context.AppUserBookmark.RemoveRange(Context.AppUserBookmark.ToList()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } #endregion @@ -72,18 +68,18 @@ public class CleanupServiceTests : AbstractDbTest var s = new SeriesBuilder("Test 1").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); s = new SeriesBuilder("Test 2").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(3)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); s = new SeriesBuilder("Test 3").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(1000)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.DeleteSeriesCoverImages(); @@ -106,16 +102,16 @@ public class CleanupServiceTests : AbstractDbTest var s = new SeriesBuilder("Test 1").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); s = new SeriesBuilder("Test 2").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(3)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.DeleteSeriesCoverImages(); @@ -137,7 +133,7 @@ public class CleanupServiceTests : AbstractDbTest await ResetDb(); // Add 2 series with cover images - _context.Series.Add(new SeriesBuilder("Test 1") + Context.Series.Add(new SeriesBuilder("Test 1") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithCoverImage("v01_c01.jpg").Build()) .WithCoverImage("v01_c01.jpg") @@ -146,7 +142,7 @@ public class CleanupServiceTests : AbstractDbTest .WithLibraryId(1) .Build()); - _context.Series.Add(new SeriesBuilder("Test 2") + Context.Series.Add(new SeriesBuilder("Test 2") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithCoverImage("v01_c03.jpg").Build()) .WithCoverImage("v01_c03.jpg") @@ -156,9 +152,9 @@ public class CleanupServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.DeleteChapterCoverImages(); @@ -227,7 +223,7 @@ public class CleanupServiceTests : AbstractDbTest // Delete all Series to reset state await ResetDb(); - _context.Users.Add(new AppUser() + Context.Users.Add(new AppUser() { UserName = "Joe", ReadingLists = new List() @@ -243,9 +239,9 @@ public class CleanupServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.DeleteReadingListCoverImages(); @@ -264,7 +260,7 @@ public class CleanupServiceTests : AbstractDbTest filesystem.AddFile($"{CacheDirectory}02.jpg", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); cleanupService.CleanupCacheAndTempDirectories(); Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories)); @@ -278,7 +274,7 @@ public class CleanupServiceTests : AbstractDbTest filesystem.AddFile($"{CacheDirectory}subdir/02.jpg", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); cleanupService.CleanupCacheAndTempDirectories(); Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories)); @@ -301,7 +297,7 @@ public class CleanupServiceTests : AbstractDbTest filesystem.AddFile($"{BackupDirectory}randomfile.zip", filesystemFile); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.CleanupBackups(); Assert.Single(ds.GetFiles(BackupDirectory, searchOption: SearchOption.AllDirectories)); @@ -323,7 +319,7 @@ public class CleanupServiceTests : AbstractDbTest }); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.CleanupBackups(); Assert.True(filesystem.File.Exists($"{BackupDirectory}randomfile.zip")); @@ -347,7 +343,7 @@ public class CleanupServiceTests : AbstractDbTest } var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.CleanupLogs(); Assert.Single(ds.GetFiles(LogDirectory, searchOption: SearchOption.AllDirectories)); @@ -376,7 +372,7 @@ public class CleanupServiceTests : AbstractDbTest var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.CleanupLogs(); Assert.True(filesystem.File.Exists($"{LogDirectory}kavita20200911.log")); @@ -400,36 +396,36 @@ public class CleanupServiceTests : AbstractDbTest .Build(); series.Library = new LibraryBuilder("Test LIb").Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await _readerService.MarkChaptersUntilAsRead(user, 1, 5); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate correct chapters have read status - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); - var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem())); // Delete the Chapter - _context.Chapter.Remove(c); - await _unitOfWork.CommitAsync(); - Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); + Context.Chapter.Remove(c); + await UnitOfWork.CommitAsync(); + Assert.Empty(await UnitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); // NOTE: This may not be needed, the underlying DB structure seems fixed as of v0.7 await cleanupService.CleanupDbEntries(); - Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); + Assert.Empty(await UnitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); } [Fact] @@ -440,7 +436,7 @@ public class CleanupServiceTests : AbstractDbTest .WithMetadata(new SeriesMetadataBuilder().Build()) .Build(); s.Library = new LibraryBuilder("Test LIb").Build(); - _context.Series.Add(s); + Context.Series.Add(s); var c = new AppUserCollection() { @@ -450,24 +446,24 @@ public class CleanupServiceTests : AbstractDbTest Items = new List() {s} }; - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007", Collections = new List() {c} }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem())); // Delete the Chapter - _context.Series.Remove(s); - await _unitOfWork.CommitAsync(); + Context.Series.Remove(s); + await UnitOfWork.CommitAsync(); await cleanupService.CleanupDbEntries(); - Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()); + Assert.Empty(await UnitOfWork.CollectionTagRepository.GetAllCollectionsAsync()); } #endregion @@ -484,15 +480,15 @@ public class CleanupServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test LIb").Build(); - _context.Series.Add(s); + Context.Series.Add(s); var user = new AppUser() { UserName = "CleanupWantToRead_ShouldRemoveFullyReadSeries", }; - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); // Add want to read user.WantToRead = new List() @@ -502,12 +498,12 @@ public class CleanupServiceTests : AbstractDbTest SeriesId = s.Id } }; - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); await _readerService.MarkSeriesAsRead(user, s.Id); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); - var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem())); @@ -515,12 +511,77 @@ public class CleanupServiceTests : AbstractDbTest await cleanupService.CleanupWantToRead(); var wantToRead = - await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, new UserParams(), new FilterDto()); + await UnitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, new UserParams(), new FilterDto()); Assert.Equal(0, wantToRead.TotalCount); } #endregion + #region ConsolidateProgress + + [Fact] + public async Task ConsolidateProgress_ShouldRemoveDuplicates() + { + await ResetDb(); + + var s = new SeriesBuilder("Test ConsolidateProgress_ShouldRemoveDuplicates") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPages(3) + .Build()) + .Build()) + .Build(); + + s.Library = new LibraryBuilder("Test Lib").Build(); + Context.Series.Add(s); + + var user = new AppUser() + { + UserName = "ConsolidateProgress_ShouldRemoveDuplicates", + }; + Context.AppUser.Add(user); + + await UnitOfWork.CommitAsync(); + + // Add 2 progress events + user.Progresses ??= []; + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 1, + }); + await UnitOfWork.CommitAsync(); + + // Add a duplicate with higher page number + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 3, + }); + await UnitOfWork.CommitAsync(); + + Assert.Equal(2, (await UnitOfWork.AppUserProgressRepository.GetAllProgress()).Count()); + + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, + Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + + + await cleanupService.ConsolidateProgress(); + + var progress = await UnitOfWork.AppUserProgressRepository.GetAllProgress(); + + Assert.Single(progress); + Assert.True(progress.First().PagesRead == 3); + } + #endregion + #region EnsureChapterProgressIsCapped @@ -540,54 +601,54 @@ public class CleanupServiceTests : AbstractDbTest { new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume).WithChapter(c).Build() }; - _context.Series.Add(s); + Context.Series.Add(s); var user = new AppUser() { UserName = "EnsureChapterProgressIsCapped", Progresses = new List() }; - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); await _readerService.MarkChaptersAsRead(user, s.Id, new List() {c}); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); - var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); - await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); + var chapter = await UnitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); + await UnitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); Assert.NotNull(chapter); Assert.Equal(2, chapter.PagesRead); // Update chapter to have 1 page c.Pages = 1; - _unitOfWork.ChapterRepository.Update(c); - await _unitOfWork.CommitAsync(); + UnitOfWork.ChapterRepository.Update(c); + await UnitOfWork.CommitAsync(); - chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); - await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); + chapter = await UnitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); + await UnitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); Assert.NotNull(chapter); Assert.Equal(2, chapter.PagesRead); Assert.Equal(1, chapter.Pages); - var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem())); await cleanupService.EnsureChapterProgressIsCapped(); - chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); - await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); + chapter = await UnitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); + await UnitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); Assert.NotNull(chapter); Assert.Equal(1, chapter.PagesRead); - _context.AppUser.Remove(user); - await _unitOfWork.CommitAsync(); + Context.AppUser.Remove(user); + await UnitOfWork.CommitAsync(); } #endregion - // #region CleanupBookmarks + #region CleanupBookmarks // // [Fact] // public async Task CleanupBookmarks_LeaveAllFiles() @@ -724,5 +785,5 @@ public class CleanupServiceTests : AbstractDbTest // Assert.Equal(1, ds.FileSystem.Directory.GetDirectories($"{BookmarkDirectory}1/1/").Length); // } // - // #endregion + #endregion } diff --git a/API.Tests/Services/CollectionTagServiceTests.cs b/API.Tests/Services/CollectionTagServiceTests.cs index 85e8391fe..3414dd86b 100644 --- a/API.Tests/Services/CollectionTagServiceTests.cs +++ b/API.Tests/Services/CollectionTagServiceTests.cs @@ -1,6 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs.Collection; @@ -10,6 +12,7 @@ using API.Helpers.Builders; using API.Services; using API.Services.Plus; using API.SignalR; +using Kavita.Common; using NSubstitute; using Xunit; @@ -20,24 +23,24 @@ public class CollectionTagServiceTests : AbstractDbTest private readonly ICollectionTagService _service; public CollectionTagServiceTests() { - _service = new CollectionTagService(_unitOfWork, Substitute.For()); + _service = new CollectionTagService(UnitOfWork, Substitute.For()); } protected override async Task ResetDb() { - _context.AppUserCollection.RemoveRange(_context.AppUserCollection.ToList()); - _context.Library.RemoveRange(_context.Library.ToList()); + Context.AppUserCollection.RemoveRange(Context.AppUserCollection.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); } private async Task SeedSeries() { - if (_context.AppUserCollection.Any()) return; + if (Context.AppUserCollection.Any()) return; var s1 = new SeriesBuilder("Series 1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()).Build(); var s2 = new SeriesBuilder("Series 2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()).Build(); - _context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga) + Context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga) .WithSeries(s1) .WithSeries(s2) .Build()); @@ -48,11 +51,69 @@ public class CollectionTagServiceTests : AbstractDbTest new AppUserCollectionBuilder("Tag 1").WithItems(new []{s1}).Build(), new AppUserCollectionBuilder("Tag 2").WithItems(new []{s1, s2}).WithIsPromoted(true).Build() }; - _unitOfWork.UserRepository.Add(user); + UnitOfWork.UserRepository.Add(user); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); } + #region DeleteTag + + [Fact] + public async Task DeleteTag_ShouldDeleteTag_WhenTagExists() + { + // Arrange + await SeedSeries(); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act + var result = await _service.DeleteTag(1, user); + + // Assert + Assert.True(result); + var deletedTag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(deletedTag); + Assert.Single(user.Collections); // Only one collection should remain + } + + [Fact] + public async Task DeleteTag_ShouldReturnTrue_WhenTagDoesNotExist() + { + // Arrange + await SeedSeries(); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act - Try to delete a non-existent tag + var result = await _service.DeleteTag(999, user); + + // Assert + Assert.True(result); // Should return true because the tag is already "deleted" + Assert.Equal(2, user.Collections.Count); // Both collections should remain + } + + [Fact] + public async Task DeleteTag_ShouldNotAffectOtherTags() + { + // Arrange + await SeedSeries(); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act + var result = await _service.DeleteTag(1, user); + + // Assert + Assert.True(result); + var remainingTag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(remainingTag); + Assert.Equal("Tag 2", remainingTag.Title); + Assert.True(remainingTag.Promoted); + } + + #endregion + #region UpdateTag [Fact] @@ -60,12 +121,12 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldUpdateFields").WithIsPromoted(true).Build()); - _unitOfWork.UserRepository.Update(user); - await _unitOfWork.CommitAsync(); + UnitOfWork.UserRepository.Update(user); + await UnitOfWork.CommitAsync(); await _service.UpdateTag(new AppUserCollectionDto() { @@ -76,7 +137,7 @@ public class CollectionTagServiceTests : AbstractDbTest AgeRating = AgeRating.Unknown }, 1); - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(3); Assert.NotNull(tag); Assert.True(tag.Promoted); Assert.False(string.IsNullOrEmpty(tag.Summary)); @@ -90,12 +151,12 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource").WithSource(ScrobbleProvider.Mal).Build()); - _unitOfWork.UserRepository.Update(user); - await _unitOfWork.CommitAsync(); + UnitOfWork.UserRepository.Update(user); + await UnitOfWork.CommitAsync(); await _service.UpdateTag(new AppUserCollectionDto() { @@ -106,11 +167,194 @@ public class CollectionTagServiceTests : AbstractDbTest AgeRating = AgeRating.Unknown }, 1); - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(3); Assert.NotNull(tag); Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title); Assert.False(string.IsNullOrEmpty(tag.Summary)); } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTagDoesNotExist() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Non-existent Tag", + Id = 999, // Non-existent ID + Promoted = false + }, 1)); + + Assert.Equal("collection-doesnt-exist", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenUserDoesNotOwnTag() + { + // Arrange + await SeedSeries(); + + // Create a second user + var user2 = new AppUserBuilder("user2", "user2", Seed.DefaultThemes.First()).Build(); + UnitOfWork.UserRepository.Add(user2); + await UnitOfWork.CommitAsync(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, // This belongs to user1 + Promoted = false + }, 2)); // User with ID 2 + + Assert.Equal("access-denied", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTitleIsEmpty() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = " ", // Empty after trimming + Id = 1, + Promoted = false + }, 1)); + + Assert.Equal("collection-tag-title-required", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTitleAlreadyExists() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 2", // Already exists + Id = 1, // Trying to rename Tag 1 to Tag 2 + Promoted = false + }, 1)); + + Assert.Equal("collection-tag-duplicate", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldUpdateCoverImageSettings() + { + // Arrange + await SeedSeries(); + + // Act + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + CoverImageLocked = true + }, 1); + + // Assert + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.CoverImageLocked); + + // Now test unlocking the cover image + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + CoverImageLocked = false + }, 1); + + tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.False(tag.CoverImageLocked); + Assert.Equal(string.Empty, tag.CoverImage); + } + + [Fact] + public async Task UpdateTag_ShouldAllowPromoteForAdminRole() + { + // Arrange + await SeedSeries(); + + // Setup a user with admin role + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + await AddUserWithRole(user.Id, PolicyConstants.AdminRole); + + + // Act - Try to promote a tag that wasn't previously promoted + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.Promoted); + } + + [Fact] + public async Task UpdateTag_ShouldAllowPromoteForPromoteRole() + { + // Arrange + await SeedSeries(); + + // Setup a user with promote role + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Mock to return promote role for the user + await AddUserWithRole(user.Id, PolicyConstants.PromoteRole); + + // Act - Try to promote a tag that wasn't previously promoted + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.Promoted); + } + + [Fact] + public async Task UpdateTag_ShouldNotChangePromotion_WhenUserHasNoPermission() + { + // Arrange + await SeedSeries(); + + // Setup a user with no special roles + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act - Try to promote a tag without proper role + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.False(tag.Promoted); // Should remain unpromoted + } #endregion @@ -121,17 +365,17 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); // Tag 2 has 2 series - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); Assert.NotNull(tag); await _service.RemoveTagFromSeries(tag, new[] {1}); - var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var userCollections = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.Equal(2, userCollections!.Collections.Count); - Assert.Equal(1, tag.Items.Count); + Assert.Single(tag.Items); Assert.Equal(2, tag.Items.First().Id); } @@ -143,11 +387,11 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); // Tag 2 has 2 series - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); Assert.NotNull(tag); await _service.RemoveTagFromSeries(tag, new[] {1}); @@ -163,18 +407,123 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); // Tag 1 has 1 series - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); Assert.NotNull(tag); await _service.RemoveTagFromSeries(tag, new[] {1}); - var tag2 = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + var tag2 = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); Assert.Null(tag2); } + [Fact] + public async Task RemoveTagFromSeries_ShouldReturnFalse_WhenTagIsNull() + { + // Act + var result = await _service.RemoveTagFromSeries(null, [1]); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleEmptySeriesIdsList() + { + // Arrange + await SeedSeries(); + + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + var initialItemCount = tag.Items.Count; + + // Act + var result = await _service.RemoveTagFromSeries(tag, Array.Empty()); + + // Assert + Assert.True(result); + tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleNonExistentSeriesIds() + { + // Arrange + await SeedSeries(); + + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + var initialItemCount = tag.Items.Count; + + // Act - Try to remove a series that doesn't exist in the tag + var result = await _service.RemoveTagFromSeries(tag, [999]); + + // Assert + Assert.True(result); + tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleNullItemsList() + { + // Arrange + await SeedSeries(); + + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + + // Force null items list + tag.Items = null; + UnitOfWork.CollectionTagRepository.Update(tag); + await UnitOfWork.CommitAsync(); + + // Act + var result = await _service.RemoveTagFromSeries(tag, [1]); + + // Assert + Assert.True(result); + // The tag should not be removed since the items list was null, not empty + var tagAfter = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(tagAfter); + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldUpdateAgeRating_WhenMultipleSeriesRemain() + { + // Arrange + await SeedSeries(); + + // Add a third series with a different age rating + var s3 = new SeriesBuilder("Series 3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.PG).Build()).Build(); + Context.Library.First().Series.Add(s3); + await UnitOfWork.CommitAsync(); + + // Add series 3 to tag 2 + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + tag.Items.Add(s3); + UnitOfWork.CollectionTagRepository.Update(tag); + await UnitOfWork.CommitAsync(); + + // Act - Remove the series with Mature rating + await _service.RemoveTagFromSeries(tag, new[] {1}); + + // Assert + tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + Assert.Equal(2, tag.Items.Count); + + // The age rating should be updated to the highest remaining rating (PG) + Assert.Equal(AgeRating.PG, tag.AgeRating); + } + + #endregion } diff --git a/API.Tests/Services/CoverDbServiceTests.cs b/API.Tests/Services/CoverDbServiceTests.cs new file mode 100644 index 000000000..93217c3b5 --- /dev/null +++ b/API.Tests/Services/CoverDbServiceTests.cs @@ -0,0 +1,117 @@ +using System.IO; +using System.IO.Abstractions; +using System.Reflection; +using System.Threading.Tasks; +using API.Constants; +using API.Entities.Enums; +using API.Extensions; +using API.Services; +using API.Services.Tasks.Metadata; +using API.SignalR; +using EasyCaching.Core; +using Kavita.Common; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class CoverDbServiceTests : AbstractDbTest +{ + private readonly DirectoryService _directoryService; + private readonly IEasyCachingProviderFactory _cacheFactory = Substitute.For(); + private readonly ICoverDbService _coverDbService; + + private static readonly string FaviconPath = Path.Join(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/CoverDbService/Favicons"); + /// + /// Path to download files temp to. Should be empty after each test. + /// + private static readonly string TempPath = Path.Join(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/CoverDbService/Temp"); + + public CoverDbServiceTests() + { + _directoryService = new DirectoryService(Substitute.For>(), CreateFileSystem()); + var imageService = new ImageService(Substitute.For>(), _directoryService); + + _coverDbService = new CoverDbService(Substitute.For>(), _directoryService, _cacheFactory, + Substitute.For(), imageService, UnitOfWork, Substitute.For()); + } + + protected override Task ResetDb() + { + throw new System.NotImplementedException(); + } + + + #region Download Favicon + + /// + /// I cannot figure out how to test this code due to the reliance on the _directoryService.FaviconDirectory and not being + /// able to redirect it to the real filesystem. + /// + public async Task DownloadFaviconAsync_ShouldDownloadAndMatchExpectedFavicon() + { + // Arrange + var testUrl = "https://anilist.co/anime/6205/Kmpfer/"; + var encodeFormat = EncodeFormat.WEBP; + var expectedFaviconPath = Path.Combine(FaviconPath, "anilist.co.webp"); + + // Ensure TempPath exists + _directoryService.ExistOrCreate(TempPath); + + var baseUrl = "https://anilist.co"; + + // Ensure there is no cache result for this URL + var provider = Substitute.For(); + provider.GetAsync(baseUrl).Returns(new CacheValue(null, false)); + _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon).Returns(provider); + + + // // Replace favicon directory with TempPath + // var directoryService = (DirectoryService)_directoryService; + // directoryService.FaviconDirectory = TempPath; + + // Hack: Swap FaviconDirectory with TempPath for ability to download real files + typeof(DirectoryService) + .GetField("FaviconDirectory", BindingFlags.NonPublic | BindingFlags.Instance) + ?.SetValue(_directoryService, TempPath); + + + // Act + var resultFilename = await _coverDbService.DownloadFaviconAsync(testUrl, encodeFormat); + var actualFaviconPath = Path.Combine(TempPath, resultFilename); + + // Assert file exists + Assert.True(File.Exists(actualFaviconPath), "Downloaded favicon does not exist in temp path"); + + // Load and compare similarity + + var similarity = expectedFaviconPath.CalculateSimilarity(actualFaviconPath); // Assuming you have this extension + Assert.True(similarity > 0.9f, $"Image similarity too low: {similarity}"); + } + + [Fact] + public async Task DownloadFaviconAsync_ShouldThrowKavitaException_WhenPreviouslyFailedUrlExistsInCache() + { + // Arrange + var testUrl = "https://example.com"; + var encodeFormat = EncodeFormat.WEBP; + + var provider = Substitute.For(); + provider.GetAsync(Arg.Any()) + .Returns(new CacheValue(string.Empty, true)); // Simulate previous failure + + _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon).Returns(provider); + + // Act & Assert + await Assert.ThrowsAsync(() => + _coverDbService.DownloadFaviconAsync(testUrl, encodeFormat)); + } + + #endregion + + +} diff --git a/API.Tests/Services/DeviceServiceTests.cs b/API.Tests/Services/DeviceServiceTests.cs index 1d021c76d..cbcf70f82 100644 --- a/API.Tests/Services/DeviceServiceTests.cs +++ b/API.Tests/Services/DeviceServiceTests.cs @@ -18,13 +18,13 @@ public class DeviceServiceDbTests : AbstractDbTest public DeviceServiceDbTests() : base() { - _deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For()); + _deviceService = new DeviceService(UnitOfWork, _logger, Substitute.For()); } protected override async Task ResetDb() { - _context.Users.RemoveRange(_context.Users.ToList()); - await _unitOfWork.CommitAsync(); + Context.Users.RemoveRange(Context.Users.ToList()); + await UnitOfWork.CommitAsync(); } @@ -39,8 +39,8 @@ public class DeviceServiceDbTests : AbstractDbTest Devices = new List() }; - _context.Users.Add(user); - await _unitOfWork.CommitAsync(); + Context.Users.Add(user); + await UnitOfWork.CommitAsync(); var device = await _deviceService.Create(new CreateDeviceDto() { @@ -62,8 +62,8 @@ public class DeviceServiceDbTests : AbstractDbTest Devices = new List() }; - _context.Users.Add(user); - await _unitOfWork.CommitAsync(); + Context.Users.Add(user); + await UnitOfWork.CommitAsync(); var device = await _deviceService.Create(new CreateDeviceDto() { diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 737779f0f..c5216bebf 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; using API.Services; @@ -10,12 +12,19 @@ using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Xunit.Abstractions; namespace API.Tests.Services; -public class DirectoryServiceTests +public class DirectoryServiceTests: AbstractFsTest { private readonly ILogger _logger = Substitute.For>(); + private readonly ITestOutputHelper _testOutputHelper; + + public DirectoryServiceTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } #region TraverseTreeParallelForEach @@ -373,9 +382,16 @@ public class DirectoryServiceTests #endregion #region IsDriveMounted + // The root directory (/) is always mounted on non windows [Fact] public void IsDriveMounted_DriveIsNotMounted() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _testOutputHelper.WriteLine("Skipping test on non Windows platform"); + return; + } + const string testDirectory = "c:/manga/"; var fileSystem = new MockFileSystem(); fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); @@ -387,6 +403,12 @@ public class DirectoryServiceTests [Fact] public void IsDriveMounted_DriveIsMounted() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _testOutputHelper.WriteLine("Skipping test on non Windows platform"); + return; + } + const string testDirectory = "c:/manga/"; var fileSystem = new MockFileSystem(); fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); @@ -900,12 +922,14 @@ public class DirectoryServiceTests #region GetHumanReadableBytes [Theory] - [InlineData(1200, "1.17 KB")] - [InlineData(1, "1 B")] - [InlineData(10000000, "9.54 MB")] - [InlineData(10000000000, "9.31 GB")] - public void GetHumanReadableBytesTest(long bytes, string expected) + [InlineData(1200, 1.17, " KB")] + [InlineData(1, 1, " B")] + [InlineData(10000000, 9.54, " MB")] + [InlineData(10000000000, 9.31, " GB")] + public void GetHumanReadableBytesTest(long bytes, float number, string suffix) { + // GetHumanReadableBytes is user facing, should be in CultureInfo.CurrentCulture + var expected = number.ToString(CultureInfo.CurrentCulture) + suffix; Assert.Equal(expected, DirectoryService.GetHumanReadableBytes(bytes)); } #endregion @@ -1041,11 +1065,14 @@ public class DirectoryServiceTests #region GetParentDirectory [Theory] - [InlineData(@"C:/file.txt", "C:/")] - [InlineData(@"C:/folder/file.txt", "C:/folder")] - [InlineData(@"C:/folder/subfolder/file.txt", "C:/folder/subfolder")] + [InlineData(@"file.txt", "")] + [InlineData(@"folder/file.txt", "folder")] + [InlineData(@"folder/subfolder/file.txt", "folder/subfolder")] public void GetParentDirectoryName_ShouldFindParentOfFiles(string path, string expected) { + path = Root + path; + expected = Root + expected; + var fileSystem = new MockFileSystem(new Dictionary { { path, new MockFileData(string.Empty)} @@ -1055,11 +1082,14 @@ public class DirectoryServiceTests Assert.Equal(expected, ds.GetParentDirectoryName(path)); } [Theory] - [InlineData(@"C:/folder", "C:/")] - [InlineData(@"C:/folder/subfolder", "C:/folder")] - [InlineData(@"C:/folder/subfolder/another", "C:/folder/subfolder")] + [InlineData(@"folder", "")] + [InlineData(@"folder/subfolder", "folder")] + [InlineData(@"folder/subfolder/another", "folder/subfolder")] public void GetParentDirectoryName_ShouldFindParentOfDirectories(string path, string expected) { + path = Root + path; + expected = Root + expected; + var fileSystem = new MockFileSystem(); fileSystem.AddDirectory(path); diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs new file mode 100644 index 000000000..973b7c6df --- /dev/null +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -0,0 +1,3198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; +using API.DTOs.Scrobbling; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; +using API.Helpers.Builders; +using API.Services.Plus; +using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; +using API.SignalR; +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +/// +/// Given these rely on Kavita+, this will not have any [Fact]/[Theory] on them and must be manually checked +/// +public class ExternalMetadataServiceTests : AbstractDbTest +{ + private readonly ExternalMetadataService _externalMetadataService; + private readonly Dictionary _genreLookup = new Dictionary(); + private readonly Dictionary _tagLookup = new Dictionary(); + private readonly Dictionary _personLookup = new Dictionary(); + + + public ExternalMetadataServiceTests() + { + // Set up Hangfire to use in-memory storage for testing + GlobalConfiguration.Configuration.UseInMemoryStorage(); + + _externalMetadataService = new ExternalMetadataService(UnitOfWork, Substitute.For>(), + Mapper, Substitute.For(), Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); + } + + #region Gloabl + + [Fact] + public async Task Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = false; + metadataSettings.EnableSummary = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.Metadata.Summary); + } + + #endregion + + #region Summary + + [Fact] + public async Task Summary_NoExisting_Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal(series.Metadata.Summary, postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked") + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should not write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This summary is not locked", postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked", true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should not write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This summary is not locked", postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked", true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + metadataSettings.Overrides = [MetadataSettingField.Summary]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This should write", postSeries.Metadata.Summary); + } + + + #endregion + + #region Release Year + + [Fact] + public async Task ReleaseYear_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(0, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(DateTime.UtcNow.Year, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(1990, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990, true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(1990, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990, true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + metadataSettings.Overrides = [MetadataSettingField.StartDate]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(DateTime.UtcNow.Year, postSeries.Metadata.ReleaseYear); + } + + #endregion + + #region LocalizedName + + [Fact] + public async Task LocalizedName_NoExisting_Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Kimchi", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here") + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Localized Name here", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here", true) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Localized Name here", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here", true) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + metadataSettings.Overrides = [MetadataSettingField.LocalizedName]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Kimchi", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_OnlyNonEnglishSynonyms_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.True(string.IsNullOrEmpty(postSeries.LocalizedName)); + } + + #endregion + + #region Publication Status + + [Fact] + public async Task PublicationStatus_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.OnGoing, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus, true) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Hiatus, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus, true) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + metadataSettings.Overrides = [MetadataSettingField.PublicationStatus]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_CorrectState_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Ended, postSeries.Metadata.PublicationStatus); + } + + + [Fact] + public void IsSeriesCompleted_ExactMatch() + { + const string seriesName = "Test - Exact Match"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(5) + .WithTotalCount(5) + .Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 5, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + } + + [Fact] + public void IsSeriesCompleted_Volumes_DecimalVolumes() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder("2.5").WithNumber(2.5f).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes decimal volume 2.5 + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.True(result); + Assert.Equal(3, series.Metadata.MaxCount); + Assert.Equal(3, series.Metadata.TotalCount); + } + + /// + /// This is validating that we get a completed even though we have a special chapter and AL doesn't count it + /// + [Fact] + public void IsSeriesCompleted_Volumes_HasSpecialAndDecimal_ExternalNoSpecial() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("1.5").WithNumber(1.5f).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes volume 1.5, but not the special + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.True(result); + Assert.Equal(3, series.Metadata.MaxCount); + Assert.Equal(3, series.Metadata.TotalCount); + } + + /// + /// This unit test also illustrates the bug where you may get a false positive if you had Volumes 1,2, and 2.1. While + /// missing volume 3. With the external metadata expecting non-decimal volumes. + /// i.e. it would fail if we only had one decimal volume + /// + [Fact] + public void IsSeriesCompleted_Volumes_TooManyDecimalVolumes() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder("2.1").WithNumber(2.1f).Build()) + .WithVolume(new VolumeBuilder("2.2").WithNumber(2.2f).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes no special or decimals. There are 3 volumes. And we're missing volume 3 + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.False(result); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_GEQChapterCheck() + { + // We own 11 chapters, the external metadata expects 10 + const string seriesName = "Test - Chapter MaxCount, no volumes"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(11) + .WithTotalCount(10) + .Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + Assert.Equal(11, series.Metadata.TotalCount); + Assert.Equal(11, series.Metadata.MaxCount); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_IncludeAllChaptersCheck() + { + const string seriesName = "Test - Chapter Count"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(7) + .WithTotalCount(10) + .Build()) + .Build(); + + var chapters = new List + { + new ChapterBuilder("0").Build(), + new ChapterBuilder("2").Build(), + new ChapterBuilder("3").Build(), + new ChapterBuilder("4").Build(), + new ChapterBuilder("5").Build(), + new ChapterBuilder("6").Build(), + new ChapterBuilder("7").Build(), + new ChapterBuilder("7.1").Build(), + new ChapterBuilder("7.2").Build(), + new ChapterBuilder("7.3").Build() + }; + // External metadata includes prologues (0) and extra's (7.X) + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + Assert.Equal(10, series.Metadata.TotalCount); + Assert.Equal(10, series.Metadata.MaxCount); + } + + [Fact] + public void IsSeriesCompleted_NotEnoughVolumes() + { + const string seriesName = "Test - Incomplete Volume"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(5) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 5 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.False(result); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_NotEnoughChapters() + { + const string seriesName = "Test - Incomplete Chapter"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(5) + .WithTotalCount(8) + .Build()) + .Build(); + + var chapters = new List + { + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + new ChapterBuilder("3").Build() + }; + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.False(result); + } + + + #endregion + + #region Age Rating + + [Fact] + public async Task AgeRating_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_ExistingHigher_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Mature) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Mature, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_ExistingLower_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone, true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Everyone, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone, true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.Overrides = [MetadataSettingField.AgeRating]; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + #endregion + + #region Genres + + [Fact] + public async Task Genres_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.Genres); + } + + [Fact] + public async Task Genres_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"]) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"], true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Action"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"], true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + #endregion + + #region Tags + + [Fact] + public async Task Tags_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.Tags); + } + + [Fact] + public async Task Tags_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task Tags_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithTag(_tagLookup["H"], true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["H"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task Tags_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithTag(_tagLookup["H"], true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Tags]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + #endregion + + #region People - Writers/Artists + + [Fact] + public async Task People_Writer_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer)); + } + + [Fact] + public async Task People_Writer_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["John Doe"], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Person.Name)); + } + + [Fact] + public async Task People_Writer_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Writer_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe", "Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + Assert.True( postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .FirstOrDefault(p => p.Person.Name == "John Doe")!.KavitaPlusConnection); + } + + [Fact] + public async Task People_Writer_Locked_Override_ReverseNamingMatch_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = false; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Twowheeler", "Johnny", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Writer_Locked_Override_PersonRoleNotSet_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = []; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + + [Fact] + public async Task People_Writer_OverrideReMatchDeletesOld_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe 2", "Story")] + }, 1); + + postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe 2"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + #endregion + + #region People Alias + + [Fact] + public async Task PeopleAliasing_AddAsAlias() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + Context.Person.Add(new PersonBuilder("John Doe").Build()); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Doe", "John", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Single(allWriters); + + var johnDoe = allWriters[0].Person; + + Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias)); + } + + [Fact] + public async Task PeopleAliasing_AddOnAlias() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + Context.Person.Add(new PersonBuilder("John Doe").WithAlias("Doe John").Build()); + + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Doe", "John", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Single(allWriters); + + var johnDoe = allWriters[0].Person; + + Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias)); + } + + [Fact] + public async Task PeopleAliasing_DontAddAsAlias_SameButNotSwitched() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe Doe", "Story"), CreateStaff("Doe", "John Doe", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Equal(2, allWriters.Count); + } + + #endregion + + #region People - Characters + + [Fact] + public async Task People_Character_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character)); + } + + [Fact] + public async Task People_Character_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["John Doe"], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Person.Name)); + } + + [Fact] + public async Task People_Character_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.CharacterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Character_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe", "Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + Assert.True( postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .FirstOrDefault(p => p.Person.Name == "John Doe")!.KavitaPlusConnection); + } + + [Fact] + public async Task People_Character_Locked_Override_ReverseNamingNoMatch_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = false; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("Twowheeler", "Johnny", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler", "Twowheeler Johnny"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Character_Locked_Override_PersonRoleNotSet_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = []; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + + [Fact] + public async Task People_Character_OverrideReMatchDeletesOld_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe 2", CharacterRole.Main)] + }, 1); + + postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe 2"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + #endregion + + #region Series Cover + // Not sure how to test this + #endregion + + #region Relationships + + // Not enabled + + // Non-Sequel + + [Fact] + public async Task Relationships_NonSequel() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithExternalMetadata(new ExternalSeriesMetadata() + { + AniListId = 10 + }) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + } + + [Fact] + public async Task Relationships_NonSequel_LocalizedName() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithLocalizedName("School bus") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = "School bus", + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + } + + // Non-Sequel with no match due to Format difference + [Fact] + public async Task Relationships_NonSequel_FormatDifference() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithLocalizedName("School bus") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = "School bus", + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Book + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Empty(sourceSeries.Relations); + } + + // Non-Sequel existing relationship with new link, both exist + [Fact] + public async Task Relationships_NonSequel_ExistingLink_DifferentType_BothExist() + { + await ResetDb(); + + var existingRelationshipSeries = new SeriesBuilder("Existing") + .WithLibraryId(1) + .Build(); + Context.Series.Attach(existingRelationshipSeries); + await Context.SaveChangesAsync(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithRelationship(existingRelationshipSeries.Id, RelationKind.Annual) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithExternalMetadata(new ExternalSeriesMetadata() + { + AniListId = 10 + }) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 2); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Equal(seriesName, sourceSeries.Name); + + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.Annual && r.TargetSeriesId == existingRelationshipSeries.Id); + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.SideStory && r.TargetSeriesId == series2.Id); + } + + + + // Sequel/Prequel + [Fact] + public async Task Relationships_Sequel_CreatesPrequel() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Source"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.Sequel, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + + var sequel = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sequel); + Assert.Equal(seriesName, sequel.Relations.First().TargetSeries.Name); + } + + [Fact] + public async Task Relationships_Prequel_CreatesSequel() + { + await ResetDb(); + + // ID 1: Blue Lock - Episode Nagi + var series = new SeriesBuilder("Blue Lock - Episode Nagi") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + // ID 2: Blue Lock + var series2 = new SeriesBuilder("Blue Lock") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + // Apply to Blue Lock - Episode Nagi (ID 1), setting Blue Lock (ID 2) as its prequel + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = "Blue Lock - Episode Nagi", // The series we're updating metadata for + Relations = [new SeriesRelationship() + { + Relation = RelationKind.Prequel, // Blue Lock is the prequel to Nagi + SeriesName = new ALMediaTitle() + { + PreferredTitle = "Blue Lock", + EnglishTitle = "Blue Lock", + NativeTitle = "ブルーロック", + RomajiTitle = "Blue Lock", + }, + PlusMediaFormat = PlusMediaFormat.Manga, + AniListId = 106130, + MalId = 114745, + Provider = ScrobbleProvider.AniList + }] + }, 1); // Apply to series ID 1 (Nagi) + + // Verify Blue Lock - Episode Nagi has Blue Lock as prequel + var nagiSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(nagiSeries); + Assert.Single(nagiSeries.Relations); + Assert.Equal("Blue Lock", nagiSeries.Relations.First().TargetSeries.Name); + Assert.Equal(RelationKind.Prequel, nagiSeries.Relations.First().RelationKind); + + // Verify Blue Lock has Blue Lock - Episode Nagi as sequel + var blueLockSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(blueLockSeries); + Assert.Single(blueLockSeries.Relations); + Assert.Equal("Blue Lock - Episode Nagi", blueLockSeries.Relations.First().TargetSeries.Name); + Assert.Equal(RelationKind.Sequel, blueLockSeries.Relations.First().RelationKind); + } + + + #endregion + + #region Blacklist + + [Fact] + public async Task Blacklist_Genres() + { + await ResetDb(); + + const string seriesName = "Test - Blacklist Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.EnableGenres = true; + metadataSettings.Blacklist = ["Sports", "Action"]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Boxing", "Sports", "Action"], + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Boxing"}.OrderBy(s => s), postSeries.Metadata.Genres.Select(t => t.Title).OrderBy(s => s)); + } + + + [Fact] + public async Task Blacklist_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Blacklist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.EnableGenres = true; + metadataSettings.Blacklist = ["Sports", "Action"]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Sports"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Boxing"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + // Blacklist Tag + + // Field Map then Blacklist Genre + + // Field Map then Blacklist Tag + + #endregion + + #region Whitelist + + [Fact] + public async Task Whitelist_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Whitelist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Whitelist = ["Sports", "Action"]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Sports"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Sports", "Action"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + [Fact] + public async Task Whitelist_WithFieldMap_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Whitelist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Boxing", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Sports", + ExcludeFromSource = false + + }]; + metadataSettings.Whitelist = ["Sports", "Action"]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Sports", "Action"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + #endregion + + #region Field Mapping + + [Fact] + public async Task FieldMap_GenreToGenre_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] { "Ecchi", "Fanservice" }.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + } + + [Fact] + public async Task FieldMap_GenreToGenre_RemoveSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Fanservice", + ExcludeFromSource = true + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Fanservice"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task FieldMap_TagToTag_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tag Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Ecchi"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] { "Ecchi", "Fanservice" }.OrderBy(s => s), + postSeries.Metadata.Tags.Select(g => g.Title).OrderBy(s => s) + ); + } + + [Fact] + public async Task Tags_Existing_FieldMap_RemoveSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tag Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = true + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Ecchi"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Fanservice"], postSeries.Metadata.Tags.Select(g => g.Title)); + } + + [Fact] + public async Task FieldMap_GenreToTag_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres, MetadataSettingField.Tags]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] {"Ecchi"}.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + Assert.Equal( + new[] {"Fanservice"}.OrderBy(s => s), + postSeries.Metadata.Tags.Select(g => g.Title).OrderBy(s => s) + ); + } + + + + [Fact] + public async Task FieldMap_GenreToGenre_RemoveSource_NoExternalGenre_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"]) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres, MetadataSettingField.Tags]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Action", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Adventure", + ExcludeFromSource = true + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] {"Action"}.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + } + + #endregion + + + + protected override async Task ResetDb() + { + Context.Series.RemoveRange(Context.Series); + Context.AppUser.RemoveRange(Context.AppUser); + Context.Genre.RemoveRange(Context.Genre); + Context.Tag.RemoveRange(Context.Tag); + Context.Person.RemoveRange(Context.Person); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = false; + metadataSettings.EnableSummary = false; + metadataSettings.EnableCoverImage = false; + metadataSettings.EnableLocalizedName = false; + metadataSettings.EnableGenres = false; + metadataSettings.EnablePeople = false; + metadataSettings.EnableRelationships = false; + metadataSettings.EnableTags = false; + metadataSettings.EnablePublicationStatus = false; + metadataSettings.EnableStartDate = false; + metadataSettings.FieldMappings = []; + metadataSettings.AgeRatingMappings = new Dictionary(); + Context.MetadataSettings.Update(metadataSettings); + + await Context.SaveChangesAsync(); + + Context.AppUser.Add(new AppUserBuilder("Joe", "Joe") + .WithRole(PolicyConstants.AdminRole) + .WithLibrary(await Context.Library.FirstAsync(l => l.Id == 1)) + .Build()); + + // Create a bunch of Genres for this test and store their string in _genreLookup + _genreLookup.Clear(); + var g1 = new GenreBuilder("Action").Build(); + var g2 = new GenreBuilder("Ecchi").Build(); + Context.Genre.Add(g1); + Context.Genre.Add(g2); + _genreLookup.Add("Action", g1); + _genreLookup.Add("Ecchi", g2); + + _tagLookup.Clear(); + var t1 = new TagBuilder("H").Build(); + var t2 = new TagBuilder("Boxing").Build(); + Context.Tag.Add(t1); + Context.Tag.Add(t2); + _tagLookup.Add("H", t1); + _tagLookup.Add("Boxing", t2); + + _personLookup.Clear(); + var p1 = new PersonBuilder("Johnny Twowheeler").Build(); + var p2 = new PersonBuilder("Boxing").Build(); + Context.Person.Add(p1); + Context.Person.Add(p2); + _personLookup.Add("Johnny Twowheeler", p1); + _personLookup.Add("Batman Robin", p2); + + await Context.SaveChangesAsync(); + } + + private static SeriesStaffDto CreateStaff(string first, string last, string role) + { + return new SeriesStaffDto() {Name = $"{first} {last}", Role = role, Url = "", FirstName = first, LastName = last}; + } + + private static SeriesCharacter CreateCharacter(string first, string last, CharacterRole role) + { + return new SeriesCharacter() {Name = $"{first} {last}", Description = "", Url = "", ImageUrl = "", Role = role}; + } +} diff --git a/API.Tests/Services/ImageServiceTests.cs b/API.Tests/Services/ImageServiceTests.cs index ac3c3157f..f2c87e1ad 100644 --- a/API.Tests/Services/ImageServiceTests.cs +++ b/API.Tests/Services/ImageServiceTests.cs @@ -1,14 +1,9 @@ -using System.Drawing; -using System.IO; -using System.IO.Abstractions; +using System.IO; using System.Linq; using System.Text; using API.Entities.Enums; using API.Services; -using EasyCaching.Core; -using Microsoft.Extensions.Logging; using NetVips; -using NSubstitute; using Xunit; using Image = NetVips.Image; @@ -28,6 +23,7 @@ public class ImageServiceTests public void GenerateBaseline() { GenerateFiles(BaselinePattern); + Assert.True(true); } /// @@ -38,6 +34,7 @@ public class ImageServiceTests { GenerateFiles(OutputPattern); GenerateHtmlFile(); + Assert.True(true); } private void GenerateFiles(string outputExtension) @@ -159,15 +156,15 @@ public class ImageServiceTests // Step 4: Generate HTML file GenerateHtmlFileForColorScape(); - + Assert.True(true); } private static void GenerateColorImage(string hexColor, string outputPath) { - var color = ImageService.HexToRgb(hexColor); - using var colorImage = Image.Black(200, 100); - using var output = colorImage + new[] { color.R / 255.0, color.G / 255.0, color.B / 255.0 }; - output.WriteToFile(outputPath); + var (r, g, b) = ImageService.HexToRgb(hexColor); + using var blackImage = Image.Black(200, 100); + using var colorImage = blackImage.NewFromImage(r, g, b); + colorImage.WriteToFile(outputPath); } private void GenerateHtmlFileForColorScape() diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index ff4868a8c..a732b2526 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -1,27 +1,23 @@ using System; using System.Collections.Generic; -using System.Data.Common; +using System.IO; +using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; -using API.Data; using API.Data.Metadata; using API.Data.Repositories; -using API.Entities; using API.Entities.Enums; -using API.Extensions; -using API.Helpers.Builders; using API.Services; using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner.Parser; using API.SignalR; -using AutoMapper; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; +using API.Tests.Helpers; +using Hangfire; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Xunit.Abstractions; namespace API.Tests.Services; @@ -62,53 +58,55 @@ public class MockReadingItemService : IReadingItemService throw new NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_pdfParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_basicParser.IsApplicable(path, type)) { - return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } return null; } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { - return Parse(path, rootPath, libraryRoot, type); + return Parse(path, rootPath, libraryRoot, type, enableMetadata); } } public class ParseScannedFilesTests : AbstractDbTest { private readonly ILogger _logger = Substitute.For>(); + private readonly ScannerHelper _scannerHelper; - public ParseScannedFilesTests() + public ParseScannedFilesTests(ITestOutputHelper testOutputHelper) { // Since ProcessFile relies on _readingItemService, we can implement our own versions of _readingItemService so we have control over how the calls work - + GlobalConfiguration.Configuration.UseInMemoryStorage(); + _scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper); } protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } #region MergeName @@ -196,11 +194,11 @@ public class ParseScannedFilesTests : AbstractDbTest public async Task ScanLibrariesForSeries_ShouldFindFiles() { var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Data/"); - fileSystem.AddFile("C:/Data/Accel World v1.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World v2.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World v2.pdf", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Nothing.pdf", new MockFileData(string.Empty)); + fileSystem.AddDirectory(Root + "Data/"); + fileSystem.AddFile(Root + "Data/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile(Root + "Data/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile(Root + "Data/Accel World v2.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile(Root + "Data/Nothing.pdf", new MockFileData(string.Empty)); var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, @@ -208,13 +206,13 @@ public class ParseScannedFilesTests : AbstractDbTest var library = - await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); library.Type = LibraryType.Manga; - var parsedSeries = await psf.ScanLibrariesForSeries(library, new List() {"C:/Data/"}, false, - await _unitOfWork.SeriesRepository.GetFolderPathMap(1)); + var parsedSeries = await psf.ScanLibrariesForSeries(library, new List() {Root + "Data/"}, false, + await UnitOfWork.SeriesRepository.GetFolderPathMap(1)); // Assert.Equal(3, parsedSeries.Values.Count); @@ -253,9 +251,9 @@ public class ParseScannedFilesTests : AbstractDbTest new MockReadingItemService(ds, Substitute.For()), Substitute.For()); var directoriesSeen = new HashSet(); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); - var scanResults = await psf.ScanFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + var scanResults = await psf.ScanFiles("C:/Data/", true, await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library); foreach (var scanResult in scanResults) { directoriesSeen.Add(scanResult.Folder); @@ -272,13 +270,13 @@ public class ParseScannedFilesTests : AbstractDbTest var psf = new ParseScannedFiles(Substitute.For>(), ds, new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); var directoriesSeen = new HashSet(); var scanResults = await psf.ScanFiles("C:/Data/", false, - await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library); foreach (var scanResult in scanResults) { @@ -307,10 +305,10 @@ public class ParseScannedFilesTests : AbstractDbTest var psf = new ParseScannedFiles(Substitute.For>(), ds, new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); - var scanResults = await psf.ScanFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + var scanResults = await psf.ScanFiles("C:/Data", true, await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library); Assert.Equal(2, scanResults.Count); } @@ -336,11 +334,11 @@ public class ParseScannedFilesTests : AbstractDbTest var psf = new ParseScannedFiles(Substitute.For>(), ds, new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); var scanResults = await psf.ScanFiles("C:/Data", false, - await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library); Assert.Single(scanResults); } @@ -349,4 +347,220 @@ public class ParseScannedFilesTests : AbstractDbTest #endregion + + // TODO: Add back in (removed for Hotfix v0.8.5.x) + //[Fact] + public async Task HasSeriesFolderNotChangedSinceLastScan_AllSeriesFoldersHaveChanges() + { + const string testcase = "Subfolders always scanning all series changes - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + + var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Single(frieren.Volumes); + + var executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life"); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count); + + await Task.Delay(1100); // Ensure at least one second has passed since library scan + + // Add a new chapter to a volume of the series, and scan. Validate that only, and all directories of this + // series are marked as HasChanged + var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "The Executioner and Her Way of Life"), + "The Executioner and Her Way of Life Vol. 1"); + File.Copy(Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0001.cbz"), + Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0002.cbz")); + + // 4 series, of which 2 have volumes as directories + var folderMap = await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id); + Assert.Equal(6, folderMap.Count); + + var res = await psf.ScanFiles(testDirectoryPath, true, folderMap, postLib); + var changes = res.Where(sc => sc.HasChanged).ToList(); + Assert.Equal(2, changes.Count); + // Only volumes of The Executioner and Her Way of Life should be marked as HasChanged (Spice and Wolf also has 2 volumes dirs) + Assert.Equal(2, changes.Count(sc => sc.Folder.Contains("The Executioner and Her Way of Life"))); + } + + [Fact] + public async Task HasSeriesFolderNotChangedSinceLastScan_PublisherLayout() + { + const string testcase = "Subfolder always scanning fix publisher layout - Comic.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + + var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Equal(2, frieren.Volumes.Count); + + await Task.Delay(1100); // Ensure at least one second has passed since library scan + + // Add a volume to a series, and scan. Ensure only this series is marked as HasChanged + var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "YenPress"), "The Executioner and Her Way of Life"); + File.Copy(Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1.cbz"), + Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 2.cbz")); + + var res = await psf.ScanFiles(testDirectoryPath, true, + await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + var changes = res.Count(sc => sc.HasChanged); + Assert.Equal(1, changes); + } + + // TODO: Add back in (removed for Hotfix v0.8.5.x) + //[Fact] + public async Task SubFoldersNoSubFolders_SkipAll() + { + const string testcase = "Subfolders and files at root - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + // Needs to be actual time as the write time is now, so if we set LastFolderChecked in the past + // it'll always a scan as it was changed since the last scan. + await Task.Delay(1100); // Ensure at least one second has passed since library scan + + var res = await psf.ScanFiles(testDirectoryPath, true, + await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + Assert.DoesNotContain(res, sc => sc.HasChanged); + } + + [Fact] + public async Task SubFoldersNoSubFolders_ScanAllAfterAddInRoot() + { + const string testcase = "Subfolders and files at root - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + spiceAndWolf.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(2)); + Context.Series.Update(spiceAndWolf); + await Context.SaveChangesAsync(); + + // Add file at series root + var spiceAndWolfDir = Path.Join(testDirectoryPath, "Spice and Wolf"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 1.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 4.cbz")); + + var res = await psf.ScanFiles(testDirectoryPath, true, + await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + var changes = res.Count(sc => sc.HasChanged); + Assert.Equal(2, changes); + } + + [Fact] + public async Task SubFoldersNoSubFolders_ScanAllAfterAddInSubFolder() + { + const string testcase = "Subfolders and files at root - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + spiceAndWolf.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(2)); + Context.Series.Update(spiceAndWolf); + await Context.SaveChangesAsync(); + + // Add file in subfolder + var spiceAndWolfDir = Path.Join(Path.Join(testDirectoryPath, "Spice and Wolf"), "Spice and Wolf Vol. 3"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0011.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0013.cbz")); + + var res = await psf.ScanFiles(testDirectoryPath, true, + await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + var changes = res.Count(sc => sc.HasChanged); + Assert.Equal(2, changes); + } } diff --git a/API.Tests/Services/PersonServiceTests.cs b/API.Tests/Services/PersonServiceTests.cs new file mode 100644 index 000000000..5c1929b1c --- /dev/null +++ b/API.Tests/Services/PersonServiceTests.cs @@ -0,0 +1,286 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Extensions; +using API.Helpers.Builders; +using API.Services; +using Xunit; + +namespace API.Tests.Services; + +public class PersonServiceTests: AbstractDbTest +{ + + [Fact] + public async Task PersonMerge_KeepNonEmptyMetadata() + { + var ps = new PersonService(UnitOfWork); + + var person1 = new Person + { + Name = "Casey Delores", + NormalizedName = "Casey Delores".ToNormalized(), + HardcoverId = "ANonEmptyId", + MalId = 12, + }; + + var person2 = new Person + { + Name= "Delores Casey", + NormalizedName = "Delores Casey".ToNormalized(), + Description = "Hi, I'm Delores Casey!", + Aliases = [new PersonAliasBuilder("Casey, Delores").Build()], + AniListId = 27, + }; + + UnitOfWork.PersonRepository.Attach(person1); + UnitOfWork.PersonRepository.Attach(person2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person1); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + var person = allPeople[0]; + Assert.Equal("Casey Delores", person.Name); + Assert.NotEmpty(person.Description); + Assert.Equal(27, person.AniListId); + Assert.NotNull(person.HardcoverId); + Assert.NotEmpty(person.HardcoverId); + Assert.Contains(person.Aliases, pa => pa.Alias == "Delores Casey"); + Assert.Contains(person.Aliases, pa => pa.Alias == "Casey, Delores"); + } + + [Fact] + public async Task PersonMerge_MergedPersonDestruction() + { + var ps = new PersonService(UnitOfWork); + + var person1 = new Person + { + Name = "Casey Delores", + NormalizedName = "Casey Delores".ToNormalized(), + }; + + var person2 = new Person + { + Name = "Delores Casey", + NormalizedName = "Delores Casey".ToNormalized(), + }; + + UnitOfWork.PersonRepository.Attach(person1); + UnitOfWork.PersonRepository.Attach(person2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person1); + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + } + + [Fact] + public async Task PersonMerge_RetentionChapters() + { + var ps = new PersonService(UnitOfWork); + + var library = new LibraryBuilder("My Library").Build(); + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var user = new AppUserBuilder("Amelia", "amelia@localhost") + .WithLibrary(library).Build(); + UnitOfWork.UserRepository.Add(user); + + var person = new PersonBuilder("Jillian Cowan").Build(); + + var person2 = new PersonBuilder("Cowan Jillian").Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .Build(); + + var chapter2 = new ChapterBuilder("2") + .WithPerson(person2, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + var series2 = new SeriesBuilder("Test 2") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("2") + .WithChapter(chapter2) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + UnitOfWork.SeriesRepository.Add(series2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + var mergedPerson = allPeople[0]; + + Assert.Equal("Jillian Cowan", mergedPerson.Name); + + var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(1, 1, PersonRole.Editor); + Assert.Equal(2, chapters.Count()); + + chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(1, ChapterIncludes.People); + Assert.NotNull(chapter); + Assert.Single(chapter.People); + + chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2, ChapterIncludes.People); + Assert.NotNull(chapter2); + Assert.Single(chapter2.People); + + Assert.Equal(chapter.People.First().PersonId, chapter2.People.First().PersonId); + } + + [Fact] + public async Task PersonMerge_NoDuplicateChaptersOrSeries() + { + await ResetDb(); + + var ps = new PersonService(UnitOfWork); + + var library = new LibraryBuilder("My Library").Build(); + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var user = new AppUserBuilder("Amelia", "amelia@localhost") + .WithLibrary(library).Build(); + UnitOfWork.UserRepository.Add(user); + + var person = new PersonBuilder("Jillian Cowan").Build(); + + var person2 = new PersonBuilder("Cowan Jillian").Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Colorist) + .Build(); + + var chapter2 = new ChapterBuilder("2") + .WithPerson(person2, PersonRole.Editor) + .WithPerson(person, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Editor) + .Build()) + .Build(); + + var series2 = new SeriesBuilder("Test 2") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("2") + .WithChapter(chapter2) + .Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Colorist) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + UnitOfWork.SeriesRepository.Add(series2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person); + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + var mergedPerson = await UnitOfWork.PersonRepository.GetPersonById(person.Id, PersonIncludes.All); + Assert.NotNull(mergedPerson); + Assert.Equal(3, mergedPerson.ChapterPeople.Count); + Assert.Equal(3, mergedPerson.SeriesMetadataPeople.Count); + + chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter.Id, ChapterIncludes.People); + Assert.NotNull(chapter); + Assert.Equal(2, chapter.People.Count); + Assert.Single(chapter.People.Select(p => p.Person.Id).Distinct()); + Assert.Contains(chapter.People, p => p.Role == PersonRole.Editor); + Assert.Contains(chapter.People, p => p.Role == PersonRole.Colorist); + + chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter2.Id, ChapterIncludes.People); + Assert.NotNull(chapter2); + Assert.Single(chapter2.People); + Assert.Contains(chapter2.People, p => p.Role == PersonRole.Editor); + Assert.DoesNotContain(chapter2.People, p => p.Role == PersonRole.Colorist); + + series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Metadata); + Assert.NotNull(series); + Assert.Single(series.Metadata.People); + Assert.Contains(series.Metadata.People, p => p.Role == PersonRole.Editor); + Assert.DoesNotContain(series.Metadata.People, p => p.Role == PersonRole.Colorist); + + series2 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series2.Id, SeriesIncludes.Metadata); + Assert.NotNull(series2); + Assert.Equal(2, series2.Metadata.People.Count); + Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Editor); + Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Colorist); + + + } + + [Fact] + public async Task PersonAddAlias_NoOverlap() + { + await ResetDb(); + + UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jillian Cowan").Build()); + UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jilly Cowan").WithAlias("Jolly Cowan").Build()); + await UnitOfWork.CommitAsync(); + + var ps = new PersonService(UnitOfWork); + + var person1 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jillian Cowan"); + var person2 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jilly Cowan"); + Assert.NotNull(person1); + Assert.NotNull(person2); + + // Overlap on Name + var success = await ps.UpdatePersonAliasesAsync(person1, ["Jilly Cowan"]); + Assert.False(success); + + // Overlap on alias + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan"]); + Assert.False(success); + + // No overlap + success = await ps.UpdatePersonAliasesAsync(person2, ["Jilly Joy Cowan"]); + Assert.True(success); + + // Some overlap + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]); + Assert.False(success); + + // Some overlap + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]); + Assert.False(success); + + Assert.Single(person2.Aliases); + } + + protected override async Task ResetDb() + { + Context.Person.RemoveRange(Context.Person.ToList()); + + await Context.SaveChangesAsync(); + } +} diff --git a/API.Tests/Services/ProcessSeriesTests.cs b/API.Tests/Services/ProcessSeriesTests.cs index ef5c45007..119e1bc10 100644 --- a/API.Tests/Services/ProcessSeriesTests.cs +++ b/API.Tests/Services/ProcessSeriesTests.cs @@ -1,23 +1,8 @@ -using System.IO; -using API.Data; -using API.Data.Metadata; -using API.Entities; -using API.Entities.Enums; -using API.Helpers; -using API.Helpers.Builders; -using API.Services; -using API.Services.Tasks.Metadata; -using API.Services.Tasks.Scanner; -using API.SignalR; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Xunit; - -namespace API.Tests.Services; +namespace API.Tests.Services; public class ProcessSeriesTests { - + // TODO: Implement #region UpdateSeriesMetadata diff --git a/API.Tests/Services/RatingServiceTests.cs b/API.Tests/Services/RatingServiceTests.cs new file mode 100644 index 000000000..15f4541d7 --- /dev/null +++ b/API.Tests/Services/RatingServiceTests.cs @@ -0,0 +1,189 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.DTOs; +using API.Entities.Enums; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using Hangfire; +using Hangfire.InMemory; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class RatingServiceTests: AbstractDbTest +{ + private readonly RatingService _ratingService; + + public RatingServiceTests() + { + _ratingService = new RatingService(UnitOfWork, Substitute.For(), Substitute.For>()); + } + + [Fact] + public async Task UpdateRating_ShouldSetRating() + { + await ResetDb(); + + Context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + + await Context.SaveChangesAsync(); + + + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); + + JobStorage.Current = new InMemoryStorage(); + var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 1, + UserRating = 3, + }); + + Assert.True(result); + + var ratings = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))! + .Ratings; + Assert.NotEmpty(ratings); + Assert.Equal(3, ratings.First().Rating); + } + + [Fact] + public async Task UpdateRating_ShouldUpdateExistingRating() + { + await ResetDb(); + + Context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + + await Context.SaveChangesAsync(); + + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); + + var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 1, + UserRating = 3, + }); + + Assert.True(result); + + JobStorage.Current = new InMemoryStorage(); + var ratings = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings)) + .Ratings; + Assert.NotEmpty(ratings); + Assert.Equal(3, ratings.First().Rating); + + // Update the DB again + + var result2 = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 1, + UserRating = 5, + }); + + Assert.True(result2); + + var ratings2 = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings)) + .Ratings; + Assert.NotEmpty(ratings2); + Assert.True(ratings2.Count == 1); + Assert.Equal(5, ratings2.First().Rating); + } + + [Fact] + public async Task UpdateRating_ShouldClampRatingAt5() + { + await ResetDb(); + + Context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + await Context.SaveChangesAsync(); + + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); + + var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 1, + UserRating = 10, + }); + + Assert.True(result); + + JobStorage.Current = new InMemoryStorage(); + var ratings = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", + AppUserIncludes.Ratings)!) + .Ratings; + Assert.NotEmpty(ratings); + Assert.Equal(5, ratings.First().Rating); + } + + [Fact] + public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist() + { + await ResetDb(); + + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + await Context.SaveChangesAsync(); + + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); + + var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 2, + UserRating = 5, + }); + + Assert.False(result); + + var ratings = user.Ratings; + Assert.Empty(ratings); + } + protected override async Task ResetDb() + { + Context.Series.RemoveRange(Context.Series.ToList()); + Context.AppUserRating.RemoveRange(Context.AppUserRating.ToList()); + Context.Genre.RemoveRange(Context.Genre.ToList()); + Context.CollectionTag.RemoveRange(Context.CollectionTag.ToList()); + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + + await Context.SaveChangesAsync(); + } +} diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 468c22681..0e4ab2701 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1,30 +1,19 @@ using System.Collections.Generic; -using System.Data.Common; -using System.Globalization; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; -using API.Data; using API.Data.Repositories; -using API.DTOs; using API.DTOs.Progress; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; using API.Extensions; -using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; -using API.Services.Tasks; using API.SignalR; -using API.Tests.Helpers; -using AutoMapper; using Hangfire; using Hangfire.InMemory; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -32,30 +21,16 @@ using Xunit.Abstractions; namespace API.Tests.Services; -public class ReaderServiceTests +public class ReaderServiceTests: AbstractDbTest { private readonly ITestOutputHelper _testOutputHelper; - private readonly IUnitOfWork _unitOfWork; - private readonly DataContext _context; private readonly ReaderService _readerService; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string DataDirectory = "C:/data/"; - public ReaderServiceTests(ITestOutputHelper testOutputHelper) { _testOutputHelper = testOutputHelper; - var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options; - _context = new DataContext(contextOptions); - Task.Run(SeedDb).GetAwaiter().GetResult(); - - var config = new MapperConfiguration(cfg => cfg.AddProfile()); - var mapper = config.CreateMapper(); - _unitOfWork = new UnitOfWork(_context, mapper, null); - _readerService = new ReaderService(_unitOfWork, Substitute.For>(), + _readerService = new ReaderService(UnitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For()); @@ -63,55 +38,12 @@ public class ReaderServiceTests #region Setup - private static DbConnection CreateInMemoryDatabase() + + protected override async Task ResetDb() { - var connection = new SqliteConnection("Filename=:memory:"); + Context.Series.RemoveRange(Context.Series.ToList()); - connection.Open(); - - return connection; - } - - private async Task SeedDb() - { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); - - await Seed.SeedSettings(_context, - new DirectoryService(Substitute.For>(), filesystem)); - - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; - - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; - - _context.ServerSetting.Update(setting); - - _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) - .Build()); - return await _context.SaveChangesAsync() > 0; - } - - private async Task ResetDb() - { - _context.Series.RemoveRange(_context.Series.ToList()); - - await _context.SaveChangesAsync(); - } - - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(DataDirectory); - - return fileSystem; + await Context.SaveChangesAsync(); } #endregion @@ -145,10 +77,10 @@ public class ReaderServiceTests series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); Assert.Equal(0, (await _readerService.CapPageToChapter(1, -1)).Item1); @@ -173,14 +105,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); JobStorage.Current = new InMemoryStorage(); @@ -194,7 +126,7 @@ public class ReaderServiceTests }, 1); Assert.True(successful); - Assert.NotNull(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); + Assert.NotNull(await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); } [Fact] @@ -211,14 +143,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); JobStorage.Current = new InMemoryStorage(); var successful = await _readerService.SaveReadingProgress(new ProgressDto() @@ -231,7 +163,7 @@ public class ReaderServiceTests }, 1); Assert.True(successful); - Assert.NotNull(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); + Assert.NotNull(await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); Assert.True(await _readerService.SaveReadingProgress(new ProgressDto() { @@ -242,7 +174,9 @@ public class ReaderServiceTests BookScrollId = "/h1/" }, 1)); - Assert.Equal("/h1/", (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).BookScrollId); + var userProgress = await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1); + Assert.NotNull(userProgress); + Assert.Equal("/h1/", userProgress.BookScrollId); } @@ -268,22 +202,24 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var volumes = await _unitOfWork.VolumeRepository.GetVolumes(1); - await _readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); - await _context.SaveChangesAsync(); + var volumes = await UnitOfWork.VolumeRepository.GetVolumes(1); + await _readerService.MarkChaptersAsRead(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await Context.SaveChangesAsync(); - Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + var userProgress = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + Assert.NotNull(userProgress); + Assert.Equal(2, userProgress.Progresses.Count); } #endregion @@ -306,27 +242,27 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); - await _readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + var volumes = (await UnitOfWork.VolumeRepository.GetVolumes(1)).ToList(); + await _readerService.MarkChaptersAsRead(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes[0].Chapters); - await _context.SaveChangesAsync(); - Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + await Context.SaveChangesAsync(); + Assert.Equal(2, (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); - await _readerService.MarkChaptersAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); - await _context.SaveChangesAsync(); + await _readerService.MarkChaptersAsUnread(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes[0].Chapters); + await Context.SaveChangesAsync(); - var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; + var progresses = (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; Assert.Equal(0, progresses.Max(p => p.PagesRead)); Assert.Equal(2, progresses.Count); } @@ -359,19 +295,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("2", actualChapter.Range); } @@ -393,17 +329,17 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("3-4", actualChapter.Volume.Name); Assert.Equal("1", actualChapter.Range); @@ -436,19 +372,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("31", actualChapter.Range); } @@ -476,18 +412,18 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -515,19 +451,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -550,18 +486,18 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 4, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -587,21 +523,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, actualChapter.Range); } @@ -624,13 +560,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 4, 1); Assert.Equal(-1, nextChapter); @@ -649,13 +585,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.Equal(-1, nextChapter); @@ -674,13 +610,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.Equal(-1, nextChapter); @@ -703,13 +639,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.Equal(-1, nextChapter); @@ -739,13 +675,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.Equal(-1, nextChapter); @@ -777,19 +713,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("A.cbz", actualChapter.Range); } @@ -815,19 +751,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("A.cbz", actualChapter.Range); } @@ -857,14 +793,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 3, 4, 1); Assert.Equal(-1, nextChapter); @@ -894,19 +830,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("B.cbz", actualChapter.Range); } @@ -927,21 +863,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); var user = new AppUserBuilder("majora2007", "fake").Build(); - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); await _readerService.MarkChaptersAsRead(user, 1, new List() { - series.Volumes.First().Chapters.First() + series.Volumes[0].Chapters[0] }); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes); Assert.Equal(2, actualChapter.Volume.MinNumber); } @@ -973,19 +909,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("1", actualChapter.Range); } @@ -1013,18 +949,18 @@ public class ReaderServiceTests .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 3, 5, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("22", actualChapter.Range); } @@ -1062,20 +998,20 @@ public class ReaderServiceTests .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // prevChapter should be id from ch.21 from volume 2001 var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 5, 7, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -1103,20 +1039,20 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("2", actualChapter.Range); } @@ -1139,21 +1075,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); Assert.Equal(2, prevChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("2", actualChapter.Range); } @@ -1171,14 +1107,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1199,14 +1135,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1232,14 +1168,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); Assert.Equal(-1, prevChapter); @@ -1269,20 +1205,20 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2,5, 1); - var chapterInfoDto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter); + var chapterInfoDto = await UnitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter); Assert.Equal(1, chapterInfoDto.ChapterNumber.AsFloat()); // This is first chapter of first volume @@ -1303,14 +1239,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1342,22 +1278,22 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 4, 1); Assert.NotEqual(-1, prevChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("A.cbz", actualChapter.Range); } @@ -1380,18 +1316,18 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 1, 1); Assert.NotEqual(-1, prevChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("22", actualChapter.Range); } @@ -1412,16 +1348,16 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); var user = new AppUserBuilder("majora2007", "fake").Build(); - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes); Assert.Equal(1, actualChapter.Volume.MinNumber); } @@ -1454,15 +1390,15 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1487,14 +1423,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1526,14 +1462,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1571,14 +1507,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1606,7 +1542,7 @@ public class ReaderServiceTests VolumeId = 2 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1650,14 +1586,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1679,7 +1615,7 @@ public class ReaderServiceTests VolumeId = 3 // Volume 2 id }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1705,14 +1641,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1738,14 +1674,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1773,7 +1709,7 @@ public class ReaderServiceTests VolumeId = 2 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1800,15 +1736,15 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1838,22 +1774,22 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); var user = new AppUser() { UserName = "majora2007" }; - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Mark everything but chapter 101 as read await _readerService.MarkSeriesAsRead(user, 1); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); // Unmark last chapter as read - var vol = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1); + var vol = await UnitOfWork.VolumeRepository.GetVolumeByIdAsync(1); foreach (var chapt in vol.Chapters) { await _readerService.SaveReadingProgress(new ProgressDto() @@ -1864,7 +1800,7 @@ public class ReaderServiceTests VolumeId = 1 }, 1); } - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1892,22 +1828,22 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); var user = new AppUser() { UserName = "majora2007" }; - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Mark everything but chapter 101 as read await _readerService.MarkSeriesAsRead(user, 1); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); // Unmark last chapter as read - var vol = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1); + var vol = await UnitOfWork.VolumeRepository.GetVolumeByIdAsync(1); await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 0, @@ -1922,7 +1858,7 @@ public class ReaderServiceTests SeriesId = 1, VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1945,14 +1881,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1979,7 +1915,7 @@ public class ReaderServiceTests VolumeId = 2 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2005,21 +1941,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Save progress on first volume chapters and 1st of second volume - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); await _readerService.MarkSeriesAsRead(user, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2043,14 +1979,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -2077,7 +2013,7 @@ public class ReaderServiceTests VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2106,20 +2042,20 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); await _readerService.MarkSeriesAsRead(user, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Add 2 new unread series to the Series series.Volumes[0].Chapters.Add(new ChapterBuilder("231") @@ -2128,8 +2064,8 @@ public class ReaderServiceTests series.Volumes[2].Chapters.Add(new ChapterBuilder("14.9") .WithPages(1) .Build()); - _context.Series.Attach(series); - await _context.SaveChangesAsync(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); // This tests that if you add a series later to a volume and a loose leaf chapter, we continue from that volume, rather than loose leaf var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2169,26 +2105,26 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Save progress on first volume chapters and 1st of second volume - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); await _readerService.MarkChaptersAsRead(user, 1, new List() { readChapter1, readChapter2 }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2225,14 +2161,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); await _readerService.SaveReadingProgress(new ProgressDto() { @@ -2290,7 +2226,7 @@ public class ReaderServiceTests VolumeId = 2 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2319,14 +2255,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); await _readerService.SaveReadingProgress(new ProgressDto() { @@ -2336,7 +2272,7 @@ public class ReaderServiceTests VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2351,7 +2287,7 @@ public class ReaderServiceTests SeriesId = 1, VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2366,7 +2302,7 @@ public class ReaderServiceTests SeriesId = 1, VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2396,26 +2332,26 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await _readerService.MarkChaptersUntilAsRead(user, 1, 5); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate correct chapters have read status - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); } [Fact] @@ -2436,27 +2372,27 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await _readerService.MarkChaptersUntilAsRead(user, 1, 2.5f); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate correct chapters have read status - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1))); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1))); } [Fact] @@ -2474,23 +2410,24 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + Assert.NotNull(user); await _readerService.MarkChaptersUntilAsRead(user, 1, 2); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate correct chapters have read status - Assert.True(await _unitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1)); + Assert.True(await UnitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1)); } [Fact] @@ -2525,24 +2462,24 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); const int markReadUntilNumber = 47; await _readerService.MarkChaptersUntilAsRead(user, 1, markReadUntilNumber); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(1, 1); + var volumes = await UnitOfWork.VolumeRepository.GetVolumesDtoAsync(1, 1); Assert.True(volumes.SelectMany(v => v.Chapters).All(c => { // Specials are ignored. @@ -2579,21 +2516,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - await _readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); - await _context.SaveChangesAsync(); + await _readerService.MarkSeriesAsRead(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + await Context.SaveChangesAsync(); - Assert.Equal(4, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + Assert.Equal(4, (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); } @@ -2614,27 +2551,27 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); - await _readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + var volumes = (await UnitOfWork.VolumeRepository.GetVolumes(1)).ToList(); + await _readerService.MarkChaptersAsRead(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes[0].Chapters); - await _context.SaveChangesAsync(); - Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + await Context.SaveChangesAsync(); + Assert.Equal(2, (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); - await _readerService.MarkSeriesAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); - await _context.SaveChangesAsync(); + await _readerService.MarkSeriesAsUnread(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + await Context.SaveChangesAsync(); - var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; + var progresses = (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; Assert.Equal(0, progresses.Max(p => p.PagesRead)); Assert.Equal(2, progresses.Count); } @@ -2702,31 +2639,32 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await _readerService.MarkVolumesUntilAsRead(user, 1, 2002); - await _context.SaveChangesAsync(); + Assert.NotNull(user); + await Context.SaveChangesAsync(); // Validate loose leaf chapters don't get marked as read - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); // Validate that volumes 1997 and 2002 both have their respective chapter 0 marked as read - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1)).PagesRead); // Validate that the chapter 0 of the following volume (2003) is not read - Assert.Null(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(7, 1)); + Assert.Null(await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(7, 1)); } @@ -2757,30 +2695,31 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + Assert.NotNull(user); await _readerService.MarkVolumesUntilAsRead(user, 1, 2002); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate loose leaf chapters don't get marked as read - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); // Validate volumes chapter 0 have read status - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1)).PagesRead); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1))?.PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1))?.PagesRead); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); } #endregion diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 6c24dd894..7a6ed3e0b 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -11,15 +11,11 @@ using API.DTOs.ReadingLists; using API.DTOs.ReadingLists.CBL; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; -using API.Services.Tasks; using API.SignalR; -using API.Tests.Helpers; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -583,6 +579,93 @@ public class ReadingListServiceTests Assert.Equal(AgeRating.G, readingList.AgeRating); } + [Fact] + public async Task UpdateReadingListAgeRatingForSeries() + { + await ResetDb(); + var spiceAndWolf = new SeriesBuilder("Spice and Wolf") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithVolumes([ + new VolumeBuilder("1") + .WithChapters([ + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + ]).Build() + ]).Build(); + spiceAndWolf.Metadata.AgeRating = AgeRating.Everyone; + + var othersidePicnic = new SeriesBuilder("Otherside Picnic ") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithVolumes([ + new VolumeBuilder("1") + .WithChapters([ + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + ]).Build() + ]).Build(); + othersidePicnic.Metadata.AgeRating = AgeRating.Everyone; + + _context.AppUser.Add(new AppUser() + { + UserName = "Amelia", + ReadingLists = new List(), + Libraries = new List + { + new LibraryBuilder("Test Library", LibraryType.LightNovel) + .WithSeries(spiceAndWolf) + .WithSeries(othersidePicnic) + .Build(), + }, + }); + + await _context.SaveChangesAsync(); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("Amelia", AppUserIncludes.ReadingLists); + Assert.NotNull(user); + + var myTestReadingList = new ReadingListBuilder("MyReadingList").Build(); + var mySecondTestReadingList = new ReadingListBuilder("MySecondReadingList").Build(); + var myThirdTestReadingList = new ReadingListBuilder("MyThirdReadingList").Build(); + user.ReadingLists = new List() + { + myTestReadingList, + mySecondTestReadingList, + myThirdTestReadingList, + }; + + + await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List {1, 2}, myTestReadingList); + await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List {3, 4}, myTestReadingList); + await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List {1, 2}, myThirdTestReadingList); + await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List {3, 4}, mySecondTestReadingList); + + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + await _readingListService.CalculateReadingListAgeRating(myTestReadingList); + await _readingListService.CalculateReadingListAgeRating(mySecondTestReadingList); + Assert.Equal(AgeRating.Everyone, myTestReadingList.AgeRating); + Assert.Equal(AgeRating.Everyone, mySecondTestReadingList.AgeRating); + Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating); + + await _readingListService.UpdateReadingListAgeRatingForSeries(othersidePicnic.Id, AgeRating.Mature); + await _unitOfWork.CommitAsync(); + + // Reading lists containing Otherside Picnic are updated + myTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + Assert.NotNull(myTestReadingList); + Assert.Equal(AgeRating.Mature, myTestReadingList.AgeRating); + + mySecondTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(2); + Assert.NotNull(mySecondTestReadingList); + Assert.Equal(AgeRating.Mature, mySecondTestReadingList.AgeRating); + + // Unrelated reading list is not updated + myThirdTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(3); + Assert.NotNull(myThirdTestReadingList); + Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating); + } + #endregion #region CalculateStartAndEndDates diff --git a/API.Tests/Services/ReadingProfileServiceTest.cs b/API.Tests/Services/ReadingProfileServiceTest.cs new file mode 100644 index 000000000..b3d81e5ac --- /dev/null +++ b/API.Tests/Services/ReadingProfileServiceTest.cs @@ -0,0 +1,561 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Helpers.Builders; +using API.Services; +using API.Tests.Helpers; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class ReadingProfileServiceTest: AbstractDbTest +{ + + /// + /// Does not add a default reading profile + /// + /// + public async Task<(ReadingProfileService, AppUser, Library, Series)> Setup() + { + var user = new AppUserBuilder("amelia", "amelia@localhost").Build(); + Context.AppUser.Add(user); + await UnitOfWork.CommitAsync(); + + var series = new SeriesBuilder("Spice and Wolf").Build(); + + var library = new LibraryBuilder("Manga") + .WithSeries(series) + .Build(); + + user.Libraries.Add(library); + await UnitOfWork.CommitAsync(); + + var rps = new ReadingProfileService(UnitOfWork, Substitute.For(), Mapper); + user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.UserPreferences); + + return (rps, user, library, series); + } + + [Fact] + public async Task ImplicitProfileFirst() + { + await ResetDb(); + var (rps, user, library, series) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Implicit) + .WithSeries(series) + .WithName("Implicit Profile") + .Build(); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Non-implicit Profile") + .Build(); + + user.ReadingProfiles.Add(profile); + user.ReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal("Implicit Profile", seriesProfile.Name); + + // Find parent + seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id, true); + Assert.NotNull(seriesProfile); + Assert.Equal("Non-implicit Profile", seriesProfile.Name); + } + + [Fact] + public async Task CantDeleteDefaultReadingProfile() + { + await ResetDb(); + var (rps, user, _, _) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Default) + .Build(); + Context.AppUserReadingProfiles.Add(profile); + await UnitOfWork.CommitAsync(); + + await Assert.ThrowsAsync(async () => + { + await rps.DeleteReadingProfile(user.Id, profile.Id); + }); + + var profile2 = new AppUserReadingProfileBuilder(user.Id).Build(); + Context.AppUserReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + await rps.DeleteReadingProfile(user.Id, profile2.Id); + await UnitOfWork.CommitAsync(); + + var allProfiles = await Context.AppUserReadingProfiles.ToListAsync(); + Assert.Single(allProfiles); + } + + [Fact] + public async Task CreateImplicitSeriesReadingProfile() + { + await ResetDb(); + var (rps, user, _, series) = await Setup(); + + var dto = new UserReadingProfileDto + { + ReaderMode = ReaderMode.Webtoon, + ScalingOption = ScalingOption.FitToHeight, + WidthOverride = 53, + }; + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto); + + var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.SeriesIds, s => s == series.Id); + Assert.Equal(ReadingProfileKind.Implicit, profile.Kind); + } + + [Fact] + public async Task UpdateImplicitReadingProfile_DoesNotCreateNew() + { + await ResetDb(); + var (rps, user, _, series) = await Setup(); + + var dto = new UserReadingProfileDto + { + ReaderMode = ReaderMode.Webtoon, + ScalingOption = ScalingOption.FitToHeight, + WidthOverride = 53, + }; + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto); + + var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.SeriesIds, s => s == series.Id); + Assert.Equal(ReadingProfileKind.Implicit, profile.Kind); + + dto = new UserReadingProfileDto + { + ReaderMode = ReaderMode.LeftRight, + }; + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto); + profile = await rps.GetReadingProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.SeriesIds, s => s == series.Id); + Assert.Equal(ReadingProfileKind.Implicit, profile.Kind); + Assert.Equal(ReaderMode.LeftRight, profile.ReaderMode); + + var implicitCount = await Context.AppUserReadingProfiles + .Where(p => p.Kind == ReadingProfileKind.Implicit) + .CountAsync(); + Assert.Equal(1, implicitCount); + } + + [Fact] + public async Task GetCorrectProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Series Specific") + .Build(); + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithLibrary(lib) + .WithName("Library Specific") + .Build(); + var profile3 = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Default) + .WithName("Global") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + Context.AppUserReadingProfiles.Add(profile2); + Context.AppUserReadingProfiles.Add(profile3); + + var series2 = new SeriesBuilder("Rainbows After Storms").Build(); + lib.Series.Add(series2); + + var lib2 = new LibraryBuilder("Manga2").Build(); + var series3 = new SeriesBuilder("A Tropical Fish Yearns for Snow").Build(); + lib2.Series.Add(series3); + + user.Libraries.Add(lib2); + await UnitOfWork.CommitAsync(); + + var p = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(p); + Assert.Equal("Series Specific", p.Name); + + p = await rps.GetReadingProfileDtoForSeries(user.Id, series2.Id); + Assert.NotNull(p); + Assert.Equal("Library Specific", p.Name); + + p = await rps.GetReadingProfileDtoForSeries(user.Id, series3.Id); + Assert.NotNull(p); + Assert.Equal("Global", p.Name); + } + + [Fact] + public async Task ReplaceReadingProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var profile1 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile 1") + .Build(); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithName("Profile 2") + .Build(); + + Context.AppUserReadingProfiles.Add(profile1); + Context.AppUserReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + var profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Equal("Profile 1", profile.Name); + + await rps.AddProfileToSeries(user.Id, profile2.Id, series.Id); + profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Equal("Profile 2", profile.Name); + } + + [Fact] + public async Task DeleteReadingProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var profile1 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile 1") + .Build(); + + Context.AppUserReadingProfiles.Add(profile1); + await UnitOfWork.CommitAsync(); + + await rps.ClearSeriesProfile(user.Id, series.Id); + var profiles = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id); + Assert.DoesNotContain(profiles, rp => rp.SeriesIds.Contains(series.Id)); + + } + + [Fact] + public async Task BulkAddReadingProfiles() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + for (var i = 0; i < 10; i++) + { + var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build(); + lib.Series.Add(generatedSeries); + } + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile2") + .Build(); + Context.AppUserReadingProfiles.Add(profile2); + + await UnitOfWork.CommitAsync(); + + var someSeriesIds = lib.Series.Take(lib.Series.Count / 2).Select(s => s.Id).ToList(); + await rps.BulkAddProfileToSeries(user.Id, profile.Id, someSeriesIds); + + foreach (var id in someSeriesIds) + { + var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(foundProfile); + Assert.Equal(profile.Id, foundProfile.Id); + } + + var allIds = lib.Series.Select(s => s.Id).ToList(); + await rps.BulkAddProfileToSeries(user.Id, profile2.Id, allIds); + + foreach (var id in allIds) + { + var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(foundProfile); + Assert.Equal(profile2.Id, foundProfile.Id); + } + + + } + + [Fact] + public async Task BulkAssignDeletesImplicit() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var implicitProfile = Mapper.Map(new AppUserReadingProfileBuilder(user.Id) + .Build()); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Profile 1") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + + for (var i = 0; i < 10; i++) + { + var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build(); + lib.Series.Add(generatedSeries); + } + await UnitOfWork.CommitAsync(); + + var ids = lib.Series.Select(s => s.Id).ToList(); + + foreach (var id in ids) + { + await rps.UpdateImplicitReadingProfile(user.Id, id, implicitProfile); + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind); + } + + await rps.BulkAddProfileToSeries(user.Id, profile.Id, ids); + + foreach (var id in ids) + { + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind); + } + + var implicitCount = await Context.AppUserReadingProfiles + .Where(p => p.Kind == ReadingProfileKind.Implicit) + .CountAsync(); + Assert.Equal(0, implicitCount); + } + + [Fact] + public async Task AddDeletesImplicit() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var implicitProfile = Mapper.Map(new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Implicit) + .Build()); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Profile 1") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + await UnitOfWork.CommitAsync(); + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, implicitProfile); + + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind); + + await rps.AddProfileToSeries(user.Id, profile.Id, series.Id); + + seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind); + + var implicitCount = await Context.AppUserReadingProfiles + .Where(p => p.Kind == ReadingProfileKind.Implicit) + .CountAsync(); + Assert.Equal(0, implicitCount); + } + + [Fact] + public async Task CreateReadingProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var dto = new UserReadingProfileDto + { + Name = "Profile 1", + ReaderMode = ReaderMode.LeftRight, + EmulateBook = false, + }; + + await rps.CreateReadingProfile(user.Id, dto); + + var dto2 = new UserReadingProfileDto + { + Name = "Profile 2", + ReaderMode = ReaderMode.LeftRight, + EmulateBook = false, + }; + + await rps.CreateReadingProfile(user.Id, dto2); + + var dto3 = new UserReadingProfileDto + { + Name = "Profile 1", // Not unique name + ReaderMode = ReaderMode.LeftRight, + EmulateBook = false, + }; + + await Assert.ThrowsAsync(async () => + { + await rps.CreateReadingProfile(user.Id, dto3); + }); + + var allProfiles = Context.AppUserReadingProfiles.ToList(); + Assert.Equal(2, allProfiles.Count); + } + + [Fact] + public async Task ClearSeriesProfile_RemovesImplicitAndUnlinksExplicit() + { + await ResetDb(); + var (rps, user, _, series) = await Setup(); + + var implicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithKind(ReadingProfileKind.Implicit) + .WithName("Implicit Profile") + .Build(); + + var explicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Explicit Profile") + .Build(); + + Context.AppUserReadingProfiles.Add(implicitProfile); + Context.AppUserReadingProfiles.Add(explicitProfile); + await UnitOfWork.CommitAsync(); + + var allBefore = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id); + Assert.Equal(2, allBefore.Count(rp => rp.SeriesIds.Contains(series.Id))); + + await rps.ClearSeriesProfile(user.Id, series.Id); + + var remainingProfiles = await Context.AppUserReadingProfiles.ToListAsync(); + Assert.Single(remainingProfiles); + Assert.Equal("Explicit Profile", remainingProfiles[0].Name); + Assert.Empty(remainingProfiles[0].SeriesIds); + + var profilesForSeries = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id); + Assert.DoesNotContain(profilesForSeries, rp => rp.SeriesIds.Contains(series.Id)); + } + + [Fact] + public async Task AddProfileToLibrary_AddsAndOverridesExisting() + { + await ResetDb(); + var (rps, user, lib, _) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Library Profile") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + await UnitOfWork.CommitAsync(); + + await rps.AddProfileToLibrary(user.Id, profile.Id, lib.Id); + await UnitOfWork.CommitAsync(); + + var linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.NotNull(linkedProfile); + Assert.Equal(profile.Id, linkedProfile.Id); + + var newProfile = new AppUserReadingProfileBuilder(user.Id) + .WithName("New Profile") + .Build(); + Context.AppUserReadingProfiles.Add(newProfile); + await UnitOfWork.CommitAsync(); + + await rps.AddProfileToLibrary(user.Id, newProfile.Id, lib.Id); + await UnitOfWork.CommitAsync(); + + linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.NotNull(linkedProfile); + Assert.Equal(newProfile.Id, linkedProfile.Id); + } + + [Fact] + public async Task ClearLibraryProfile_RemovesImplicitOrUnlinksExplicit() + { + await ResetDb(); + var (rps, user, lib, _) = await Setup(); + + var implicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Implicit) + .WithLibrary(lib) + .Build(); + Context.AppUserReadingProfiles.Add(implicitProfile); + await UnitOfWork.CommitAsync(); + + await rps.ClearLibraryProfile(user.Id, lib.Id); + var profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.Null(profile); + + var explicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithLibrary(lib) + .Build(); + Context.AppUserReadingProfiles.Add(explicitProfile); + await UnitOfWork.CommitAsync(); + + await rps.ClearLibraryProfile(user.Id, lib.Id); + profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.Null(profile); + + var stillExists = await Context.AppUserReadingProfiles.FindAsync(explicitProfile.Id); + Assert.NotNull(stillExists); + } + + /// + /// As response to #3793, I'm not sure if we want to keep this. It's not the most nice. But I think the idea of this test + /// is worth having. + /// + [Fact] + public void UpdateFields_UpdatesAll() + { + // Repeat to ensure booleans are flipped and actually tested + for (int i = 0; i < 10; i++) + { + var profile = new AppUserReadingProfile(); + var dto = new UserReadingProfileDto(); + + RandfHelper.SetRandomValues(profile); + RandfHelper.SetRandomValues(dto); + + ReadingProfileService.UpdateReaderProfileFields(profile, dto); + + var newDto = Mapper.Map(profile); + + Assert.True(RandfHelper.AreSimpleFieldsEqual(dto, newDto, + ["k__BackingField", "k__BackingField"])); + } + } + + + + protected override async Task ResetDb() + { + Context.AppUserReadingProfiles.RemoveRange(Context.AppUserReadingProfiles); + await UnitOfWork.CommitAsync(); + } +} diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 2c09fe9b9..c337d2311 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -1,33 +1,16 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; -using System.IO.Compression; using System.Linq; -using System.Text; -using System.Text.Json; using System.Threading.Tasks; -using System.Xml; -using System.Xml.Serialization; -using API.Data; using API.Data.Metadata; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Extensions; -using API.Helpers; -using API.Helpers.Builders; -using API.Services; -using API.Services.Plus; -using API.Services.Tasks; -using API.Services.Tasks.Metadata; -using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner.Parser; -using API.SignalR; using API.Tests.Helpers; using Hangfire; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; using Xunit.Abstractions; @@ -45,13 +28,35 @@ public class ScannerServiceTests : AbstractDbTest // Set up Hangfire to use in-memory storage for testing GlobalConfiguration.Configuration.UseInMemoryStorage(); - _scannerHelper = new ScannerHelper(_unitOfWork, testOutputHelper); + _scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper); } protected override async Task ResetDb() { - _context.Library.RemoveRange(_context.Library); - await _context.SaveChangesAsync(); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + + protected async Task SetAllSeriesLastScannedInThePast(Library library, TimeSpan? duration = null) + { + foreach (var series in library.Series) + { + await SetLastScannedInThePast(series, duration, false); + } + await Context.SaveChangesAsync(); + } + + protected async Task SetLastScannedInThePast(Series series, TimeSpan? duration = null, bool save = true) + { + duration ??= TimeSpan.FromMinutes(2); + series.LastFolderScanned = DateTime.Now.Subtract(duration.Value); + Context.Series.Update(series); + + if (save) + { + await Context.SaveChangesAsync(); + } } [Fact] @@ -61,7 +66,7 @@ public class ScannerServiceTests : AbstractDbTest var library = await _scannerHelper.GenerateScannerData(testcase); var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Equal(4, postLib.Series.Count); @@ -74,7 +79,7 @@ public class ScannerServiceTests : AbstractDbTest var library = await _scannerHelper.GenerateScannerData(testcase); var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -85,11 +90,11 @@ public class ScannerServiceTests : AbstractDbTest [Fact] public async Task ScanLibrary_FlatSeries() { - var testcase = "Flat Series - Manga.json"; + const string testcase = "Flat Series - Manga.json"; var library = await _scannerHelper.GenerateScannerData(testcase); var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -101,11 +106,26 @@ public class ScannerServiceTests : AbstractDbTest [Fact] public async Task ScanLibrary_FlatSeriesWithSpecialFolder() { - var testcase = "Flat Series with Specials Folder - Manga.json"; + const string testcase = "Flat Series with Specials Folder Alt Naming - Manga.json"; var library = await _scannerHelper.GenerateScannerData(testcase); var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(4, postLib.Series.First().Volumes.Count); + Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); + } + + [Fact] + public async Task ScanLibrary_FlatSeriesWithSpecialFolder_AlternativeNaming() + { + const string testcase = "Flat Series with Specials Folder Alt Naming - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -121,7 +141,7 @@ public class ScannerServiceTests : AbstractDbTest var library = await _scannerHelper.GenerateScannerData(testcase); var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -129,7 +149,6 @@ public class ScannerServiceTests : AbstractDbTest Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); } - [Fact] public async Task ScanLibrary_SeriesWithUnbalancedParenthesis() { @@ -138,7 +157,7 @@ public class ScannerServiceTests : AbstractDbTest var library = await _scannerHelper.GenerateScannerData(testcase); var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -169,7 +188,7 @@ public class ScannerServiceTests : AbstractDbTest var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -194,7 +213,7 @@ public class ScannerServiceTests : AbstractDbTest var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -225,7 +244,7 @@ public class ScannerServiceTests : AbstractDbTest var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -249,7 +268,7 @@ public class ScannerServiceTests : AbstractDbTest var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -269,7 +288,7 @@ public class ScannerServiceTests : AbstractDbTest var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -307,7 +326,7 @@ public class ScannerServiceTests : AbstractDbTest var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -331,7 +350,7 @@ public class ScannerServiceTests : AbstractDbTest var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -350,7 +369,7 @@ public class ScannerServiceTests : AbstractDbTest var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -380,7 +399,7 @@ public class ScannerServiceTests : AbstractDbTest var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -398,4 +417,582 @@ public class ScannerServiceTests : AbstractDbTest Assert.Equal(3, series.Volumes.Count); Assert.Equal(2, series.Volumes.First(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber)).Chapters.Count); } + + [Fact] + public async Task ScanLibrary_LocalizedSeries_MatchesFilename() + { + const string testcase = "Localized Name matches Filename - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Futoku no Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Immoral Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Single(s.Volumes); + } + + [Fact] + public async Task ScanLibrary_LocalizedSeries_MatchesFilename_SameNames() + { + const string testcase = "Localized Name matches Filename - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Futoku no Guild v01.cbz", new ComicInfo() + { + Series = "Futoku no Guild", + LocalizedSeries = "Futoku no Guild" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Futoku no Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Single(s.Volumes); + } + + [Fact] + public async Task ScanLibrary_ExcludePattern_Works() + { + const string testcase = "Exclude Pattern 1 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**/Extra/*" }]; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal(2, s.Volumes.Count); + } + + [Fact] + public async Task ScanLibrary_ExcludePattern_FlippedSlashes_Works() + { + const string testcase = "Exclude Pattern 1 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**\\Extra\\*" }]; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal(2, s.Volumes.Count); + } + + [Fact] + public async Task ScanLibrary_MultipleRoots_MultipleScans_DataPersists_Forced() + { + const string testcase = "Multiple Roots - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = + Path.Join( + Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"), + testcase.Replace(".json", string.Empty)); + library.Folders = + [ + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 1")}, + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 2")} + ]; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + var s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(2, s.Volumes.Count); + var s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + + // Make a change (copy a file into only 1 root) + var root1PlushFolder = Path.Join(testDirectoryPath, "Root 1/Antarctic Press/Plush"); + File.Copy(Path.Join(root1PlushFolder, "Plush v02.cbz"), Path.Join(root1PlushFolder, "Plush v03.cbz")); + + // Rescan to ensure nothing changes yet again + await scanner.ScanLibrary(library.Id, true); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.Equal(2, postLib.Series.Count); + s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(3, s.Volumes.Count); + s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + } + + /// + /// Regression bug appeared where multi-root and one root gets a new file, on next scan of library, + /// the series in the other root are deleted. (This is actually failing because the file in Root 1 isn't being detected) + /// + [Fact] + public async Task ScanLibrary_MultipleRoots_MultipleScans_DataPersists_NonForced() + { + const string testcase = "Multiple Roots - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = + Path.Join( + Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"), + testcase.Replace(".json", string.Empty)); + library.Folders = + [ + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 1")}, + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 2")} + ]; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + var s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(2, s.Volumes.Count); + var s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + + // Make a change (copy a file into only 1 root) + var root1PlushFolder = Path.Join(testDirectoryPath, "Root 1/Antarctic Press/Plush"); + File.Copy(Path.Join(root1PlushFolder, "Plush v02.cbz"), Path.Join(root1PlushFolder, "Plush v03.cbz")); + + // Emulate time passage by updating lastFolderScan to be a min in the past + await SetLastScannedInThePast(s); + + // Rescan to ensure nothing changes yet again + await scanner.ScanLibrary(library.Id, false); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.Equal(2, postLib.Series.Count); + s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(3, s.Volumes.Count); + s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + } + + [Fact] + public async Task ScanLibrary_AlternatingRemoval_IssueReplication() + { + // https://github.com/Kareadita/Kavita/issues/3476#issuecomment-2661635558 + const string testcase = "Alternating Removal - Manga.json"; + + // Setup: Generate test library + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/ScannerService/ScanTests", + testcase.Replace(".json", string.Empty)); + + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + + // First Scan: Everything should be added + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Second Scan: Remove Root 2, expect Accel to be removed + library.Folders = [new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }]; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + // Emulate time passage by updating lastFolderScan to be a min in the past + foreach (var s in postLib.Series) + { + s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1)); + Context.Series.Update(s); + } + await Context.SaveChangesAsync(); + + await scanner.ScanLibrary(library.Id); + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.DoesNotContain(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Third Scan: Re-add Root 2, Accel should come back + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + // Emulate time passage by updating lastFolderScan to be a min in the past + foreach (var s in postLib.Series) + { + s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1)); + Context.Series.Update(s); + } + await Context.SaveChangesAsync(); + + await scanner.ScanLibrary(library.Id); + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Accel should be back + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Emulate time passage by updating lastFolderScan to be a min in the past + await SetAllSeriesLastScannedInThePast(postLib); + + // Fourth Scan: Run again to check stability (should not remove Accel) + await scanner.ScanLibrary(library.Id); + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + } + + [Fact] + public async Task ScanLibrary_DeleteSeriesInUI_ComeBack() + { + const string testcase = "Delete Series In UI - Manga.json"; + + // Setup: Generate test library + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/ScannerService/ScanTests", + testcase.Replace(".json", string.Empty)); + + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + + // First Scan: Everything should be added + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Second Scan: Delete the Series + library.Series = []; + await UnitOfWork.CommitAsync(); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Empty(postLib.Series); + + await scanner.ScanLibrary(library.Id); + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + } + + [Fact] + public async Task SubFolders_NoRemovals_ChangesFound() + { + const string testcase = "Subfolders always scanning all series changes - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + Assert.Equal(3, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Single(frieren.Volumes); + Assert.Equal(2, frieren.Volumes.Sum(v => v.Chapters.Count)); + + var executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life"); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Sum(v => v.Chapters.Count)); + + await SetAllSeriesLastScannedInThePast(postLib); + + // Add a new chapter to a volume of the series, and scan. Validate that no chapters were lost, and the new + // chapter was added + var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "The Executioner and Her Way of Life"), + "The Executioner and Her Way of Life Vol. 1"); + File.Copy(Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0001.cbz"), + Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0002.cbz")); + + await scanner.ScanLibrary(library.Id); + await UnitOfWork.CommitAsync(); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + Assert.Equal(3, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Single(frieren.Volumes); + Assert.Equal(2, frieren.Volumes.Sum(v => v.Chapters.Count)); + + executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life"); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count); + Assert.Equal(3, executionerAndHerWayOfLife.Volumes.Sum(v => v.Chapters.Count)); // Incremented by 1 + } + + [Fact] + public async Task RemovalPickedUp_NoOtherChanges() + { + const string testcase = "Series removed when no other changes are made - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + + var executionerCopyDir = Path.Join(testDirectoryPath, "The Executioner and Her Way of Life"); + Directory.Delete(executionerCopyDir, true); + + await scanner.ScanLibrary(library.Id); + await UnitOfWork.CommitAsync(); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Single(postLib.Series, s => s.Name == "Spice and Wolf"); + Assert.Equal(2, postLib.Series.First().Volumes.Count); + } + + [Fact] + public async Task SubFoldersNoSubFolders_CorrectPickupAfterAdd() + { + // This test case is used in multiple tests and can result in conflict if not separated + const string testcase = "Subfolders and files at root (2) - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + await SetLastScannedInThePast(spiceAndWolf); + + // Add volume to Spice and Wolf series directory + var spiceAndWolfDir = Path.Join(testDirectoryPath, "Spice and Wolf"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 1.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 4.cbz")); + + await scanner.ScanLibrary(library.Id); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(4, spiceAndWolf.Volumes.Count); + Assert.Equal(5, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + await SetLastScannedInThePast(spiceAndWolf); + + // Add file in subfolder + spiceAndWolfDir = Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0012.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0013.cbz")); + + await scanner.ScanLibrary(library.Id); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(4, spiceAndWolf.Volumes.Count); + Assert.Equal(6, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + } + + + /// + /// Ensure when Kavita scans, the sort order of chapters is correct + /// + [Fact] + public async Task ScanLibrary_SortOrderWorks() + { + const string testcase = "Sort Order - Manga.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + + // Get the loose leaf volume and confirm each chapter aligns with expectation of Sort Order + var series = postLib.Series.First(); + Assert.NotNull(series); + + var volume = series.Volumes.FirstOrDefault(); + Assert.NotNull(volume); + + var sortedChapters = volume.Chapters.OrderBy(c => c.SortOrder).ToList(); + Assert.True(sortedChapters[0].SortOrder.Is(1f)); + Assert.True(sortedChapters[1].SortOrder.Is(4f)); + Assert.True(sortedChapters[2].SortOrder.Is(5f)); + } + + + [Fact] + public async Task ScanLibrary_MetadataDisabled_NoOverrides() + { + const string testcase = "Series with Localized No Metadata - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Immoral Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + // Disable metadata + library.EnableMetadata = false; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + // Validate that there are 2 series + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + + Assert.Contains(postLib.Series, x => x.Name == "Immoral Guild"); + Assert.Contains(postLib.Series, x => x.Name == "Futoku No Guild"); + } + + [Fact] + public async Task ScanLibrary_SortName_NoPrefix() + { + const string testcase = "Series with Prefix - Book.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + library.RemovePrefixForSortName = true; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(1, postLib.Series.Count); + + Assert.Equal("The Avengers", postLib.Series.First().Name); + Assert.Equal("Avengers", postLib.Series.First().SortName); + } } diff --git a/API.Tests/Services/ScrobblingServiceTests.cs b/API.Tests/Services/ScrobblingServiceTests.cs index d460ee4e5..9245c8ecd 100644 --- a/API.Tests/Services/ScrobblingServiceTests.cs +++ b/API.Tests/Services/ScrobblingServiceTests.cs @@ -1,11 +1,619 @@ -using API.Services.Plus; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.DTOs.Scrobbling; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Scrobble; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using API.SignalR; +using Kavita.Common; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace API.Tests.Services; #nullable enable -public class ScrobblingServiceTests +public class ScrobblingServiceTests : AbstractDbTest { + private const int ChapterPages = 100; + + /// + /// { + /// "Issuer": "Issuer", + /// "Issued At": "2025-06-15T21:01:57.615Z", + /// "Expiration": "2200-06-15T21:01:57.615Z" + /// } + /// + /// Our UnitTests will fail in 2200 :( + private const string ValidJwtToken = + "eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJleHAiOjcyNzI0NTAxMTcsImlhdCI6MTc1MDAyMTMxN30.zADmcGq_BfxbcV8vy4xw5Cbzn4COkmVINxgqpuL17Ng"; + + private readonly ScrobblingService _service; + private readonly ILicenseService _licenseService; + private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; + private readonly IEmailService _emailService; + private readonly IKavitaPlusApiService _kavitaPlusApiService; + /// + /// IReaderService, without the ScrobblingService injected + /// + private readonly IReaderService _readerService; + /// + /// IReaderService, with the _service injected + /// + private readonly IReaderService _hookedUpReaderService; + + public ScrobblingServiceTests() + { + _licenseService = Substitute.For(); + _localizationService = Substitute.For(); + _logger = Substitute.For>(); + _emailService = Substitute.For(); + _kavitaPlusApiService = Substitute.For(); + + _service = new ScrobblingService(UnitOfWork, Substitute.For(), _logger, _licenseService, + _localizationService, _emailService, _kavitaPlusApiService); + + _readerService = new ReaderService(UnitOfWork, + Substitute.For>(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For()); // Do not use the actual one + + _hookedUpReaderService = new ReaderService(UnitOfWork, + Substitute.For>(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + _service); + } + + protected override async Task ResetDb() + { + Context.ScrobbleEvent.RemoveRange(Context.ScrobbleEvent.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + Context.AppUser.RemoveRange(Context.AppUser.ToList()); + + await UnitOfWork.CommitAsync(); + } + + private async Task SeedData() + { + var series = new SeriesBuilder("Test Series") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithVolume(new VolumeBuilder("Volume 1") + .WithChapters([ + new ChapterBuilder("1") + .WithPages(ChapterPages) + .Build(), + new ChapterBuilder("2") + .WithPages(ChapterPages) + .Build(), + new ChapterBuilder("3") + .WithPages(ChapterPages) + .Build()]) + .Build()) + .WithVolume(new VolumeBuilder("Volume 2") + .WithChapters([ + new ChapterBuilder("4") + .WithPages(ChapterPages) + .Build(), + new ChapterBuilder("5") + .WithPages(ChapterPages) + .Build(), + new ChapterBuilder("6") + .WithPages(ChapterPages) + .Build()]) + .Build()) + .Build(); + + var library = new LibraryBuilder("Test Library", LibraryType.Manga) + .WithAllowScrobbling(true) + .WithSeries(series) + .Build(); + + + Context.Library.Add(library); + + var user = new AppUserBuilder("testuser", "testuser") + //.WithPreferences(new UserPreferencesBuilder().WithAniListScrobblingEnabled(true).Build()) + .Build(); + + user.UserPreferences.AniListScrobblingEnabled = true; + + UnitOfWork.UserRepository.Add(user); + + await UnitOfWork.CommitAsync(); + } + + private async Task CreateScrobbleEvent(int? seriesId = null) + { + var evt = new ScrobbleEvent + { + ScrobbleEventType = ScrobbleEventType.ChapterRead, + Format = PlusMediaFormat.Manga, + SeriesId = seriesId ?? 0, + LibraryId = 0, + AppUserId = 0, + }; + + if (seriesId != null) + { + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId.Value); + if (series != null) evt.Series = series; + } + + return evt; + } + + + #region K+ API Request Tests + + [Fact] + public async Task PostScrobbleUpdate_AuthErrors() + { + _kavitaPlusApiService.PostScrobbleUpdate(null!, "") + .ReturnsForAnyArgs(new ScrobbleResponseDto() + { + ErrorMessage = "Unauthorized" + }); + + var evt = await CreateScrobbleEvent(); + await Assert.ThrowsAsync(async () => + { + await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt); + }); + Assert.True(evt.IsErrored); + Assert.Equal("Kavita+ subscription no longer active", evt.ErrorDetails); + } + + [Fact] + public async Task PostScrobbleUpdate_UnknownSeriesLoggedAsError() + { + _kavitaPlusApiService.PostScrobbleUpdate(null!, "") + .ReturnsForAnyArgs(new ScrobbleResponseDto() + { + ErrorMessage = "Unknown Series" + }); + + await SeedData(); + var evt = await CreateScrobbleEvent(1); + + await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt); + await UnitOfWork.CommitAsync(); + Assert.True(evt.IsErrored); + + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); + Assert.True(series.IsBlacklisted); + + var errors = await UnitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(1); + Assert.Single(errors); + Assert.Equal("Series cannot be matched for Scrobbling", errors.First().Comment); + Assert.Equal(series.Id, errors.First().SeriesId); + } + + [Fact] + public async Task PostScrobbleUpdate_InvalidAccessToken() + { + _kavitaPlusApiService.PostScrobbleUpdate(null!, "") + .ReturnsForAnyArgs(new ScrobbleResponseDto() + { + ErrorMessage = "Access token is invalid" + }); + + var evt = await CreateScrobbleEvent(); + + await Assert.ThrowsAsync(async () => + { + await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt); + }); + + Assert.True(evt.IsErrored); + Assert.Equal("Access Token needs to be rotated to continue scrobbling", evt.ErrorDetails); + } + + #endregion + + #region K+ API Request data tests + + [Fact] + public async Task ProcessReadEvents_CreatesNoEventsWhenNoProgress() + { + await ResetDb(); + await SeedData(); + + // Set Returns + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + _kavitaPlusApiService.GetRateLimit(Arg.Any(), Arg.Any()) + .Returns(100); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + // Ensure CanProcessScrobbleEvent returns true + user.AniListAccessToken = ValidJwtToken; + UnitOfWork.UserRepository.Update(user); + await UnitOfWork.CommitAsync(); + + var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4); + Assert.NotNull(chapter); + + var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters); + Assert.NotNull(volume); + + // Call Scrobble without having any progress + await _service.ScrobbleReadingUpdate(1, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + } + + [Fact] + public async Task ProcessReadEvents_UpdateVolumeAndChapterData() + { + await ResetDb(); + await SeedData(); + + // Set Returns + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + _kavitaPlusApiService.GetRateLimit(Arg.Any(), Arg.Any()) + .Returns(100); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + // Ensure CanProcessScrobbleEvent returns true + user.AniListAccessToken = ValidJwtToken; + UnitOfWork.UserRepository.Update(user); + await UnitOfWork.CommitAsync(); + + var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4); + Assert.NotNull(chapter); + + var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters); + Assert.NotNull(volume); + + // Mark something as read to trigger event creation + await _readerService.MarkChaptersAsRead(user, 1, new List() {volume.Chapters[0]}); + await UnitOfWork.CommitAsync(); + + // Call Scrobble while having some progress + await _service.ScrobbleReadingUpdate(user.Id, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events); + + // Give it some (more) read progress + await _readerService.MarkChaptersAsRead(user, 1, volume.Chapters); + await _readerService.MarkChaptersAsRead(user, 1, [chapter]); + await UnitOfWork.CommitAsync(); + + await _service.ProcessUpdatesSinceLastSync(); + + await _kavitaPlusApiService.Received(1).PostScrobbleUpdate( + Arg.Is(data => + data.ChapterNumber == (int)chapter.MaxNumber && + data.VolumeNumber == (int)volume.MaxNumber + ), + Arg.Any()); + } + + #endregion + + #region Scrobble Reading Update Tests + + [Fact] + public async Task ScrobbleReadingUpdate_IgnoreNoLicense() + { + await ResetDb(); + await SeedData(); + + _licenseService.HasActiveLicense().Returns(false); + + await _service.ScrobbleReadingUpdate(1, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + } + + [Fact] + public async Task ScrobbleReadingUpdate_RemoveWhenNoProgress() + { + await ResetDb(); + await SeedData(); + + _licenseService.HasActiveLicense().Returns(true); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters); + Assert.NotNull(volume); + + await _readerService.MarkChaptersAsRead(user, 1, new List() {volume.Chapters[0]}); + await UnitOfWork.CommitAsync(); + + await _service.ScrobbleReadingUpdate(1, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events); + + var readEvent = events.First(); + Assert.False(readEvent.IsProcessed); + + await _hookedUpReaderService.MarkSeriesAsUnread(user, 1); + await UnitOfWork.CommitAsync(); + + // Existing event is deleted + await _service.ScrobbleReadingUpdate(1, 1); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + + await _hookedUpReaderService.MarkSeriesAsUnread(user, 1); + await UnitOfWork.CommitAsync(); + + // No new events are added + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + } + + [Fact] + public async Task ScrobbleReadingUpdate_UpdateExistingNotIsProcessed() + { + await ResetDb(); + await SeedData(); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + var chapter1 = await UnitOfWork.ChapterRepository.GetChapterAsync(1); + var chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2); + var chapter3 = await UnitOfWork.ChapterRepository.GetChapterAsync(3); + Assert.NotNull(chapter1); + Assert.NotNull(chapter2); + Assert.NotNull(chapter3); + + _licenseService.HasActiveLicense().Returns(true); + + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + + + await _readerService.MarkChaptersAsRead(user, 1, [chapter1]); + await UnitOfWork.CommitAsync(); + + // Scrobble update + await _service.ScrobbleReadingUpdate(1, 1); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events); + + var readEvent = events[0]; + Assert.False(readEvent.IsProcessed); + Assert.Equal(1, readEvent.ChapterNumber); + + // Mark as processed + readEvent.IsProcessed = true; + await UnitOfWork.CommitAsync(); + + await _readerService.MarkChaptersAsRead(user, 1, [chapter2]); + await UnitOfWork.CommitAsync(); + + // Scrobble update + await _service.ScrobbleReadingUpdate(1, 1); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Equal(2, events.Count); + Assert.Single(events.Where(e => e.IsProcessed).ToList()); + Assert.Single(events.Where(e => !e.IsProcessed).ToList()); + + // Should update the existing non processed event + await _readerService.MarkChaptersAsRead(user, 1, [chapter3]); + await UnitOfWork.CommitAsync(); + + // Scrobble update + await _service.ScrobbleReadingUpdate(1, 1); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Equal(2, events.Count); + Assert.Single(events.Where(e => e.IsProcessed).ToList()); + Assert.Single(events.Where(e => !e.IsProcessed).ToList()); + } + + #endregion + + #region ScrobbleWantToReadUpdate Tests + + [Fact] + public async Task ScrobbleWantToReadUpdate_NoExistingEvents_WantToRead_ShouldCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // Act + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + Assert.Single(events); + Assert.Equal(ScrobbleEventType.AddWantToRead, events[0].ScrobbleEventType); + Assert.Equal(userId, events[0].AppUserId); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_NoExistingEvents_RemoveWantToRead_ShouldCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // Act + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + Assert.Single(events); + Assert.Equal(ScrobbleEventType.RemoveWantToRead, events[0].ScrobbleEventType); + Assert.Equal(userId, events[0].AppUserId); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingWantToReadEvent_WantToRead_ShouldNotCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create an event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Act - Try to create the same event again + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.All(events, e => Assert.Equal(ScrobbleEventType.AddWantToRead, e.ScrobbleEventType)); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingWantToReadEvent_RemoveWantToRead_ShouldAddRemoveEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create a want-to-read event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Act - Now remove from want-to-read + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.Contains(events, e => e.ScrobbleEventType == ScrobbleEventType.RemoveWantToRead); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingRemoveWantToReadEvent_RemoveWantToRead_ShouldNotCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create a remove-from-want-to-read event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Act - Try to create the same event again + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.All(events, e => Assert.Equal(ScrobbleEventType.RemoveWantToRead, e.ScrobbleEventType)); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingRemoveWantToReadEvent_WantToRead_ShouldAddWantToReadEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create a remove-from-want-to-read event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Act - Now add to want-to-read + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.Contains(events, e => e.ScrobbleEventType == ScrobbleEventType.AddWantToRead); + } + + #endregion + + #region Scrobble Rating Update Test + + [Fact] + public async Task ScrobbleRatingUpdate_IgnoreNoLicense() + { + await ResetDb(); + await SeedData(); + + _licenseService.HasActiveLicense().Returns(false); + + await _service.ScrobbleRatingUpdate(1, 1, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + } + + [Fact] + public async Task ScrobbleRatingUpdate_UpdateExistingNotIsProcessed() + { + await ResetDb(); + await SeedData(); + + _licenseService.HasActiveLicense().Returns(true); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); + + await _service.ScrobbleRatingUpdate(user.Id, series.Id, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events); + Assert.Equal(1, events.First().Rating); + + // Mark as processed + events.First().IsProcessed = true; + await UnitOfWork.CommitAsync(); + + await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Equal(2, events.Count); + Assert.Single(events, evt => evt.IsProcessed); + Assert.Single(events, evt => !evt.IsProcessed); + + await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events, evt => !evt.IsProcessed); + Assert.Equal(5, events.First(evt => !evt.IsProcessed).Rating); + + } + + #endregion + [Theory] [InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)] [InlineData("https://anilist.co/manga/30105", 30105)] diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 385b63f51..55babf815 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO.Abstractions; using System.Linq; using System.Threading.Tasks; @@ -7,10 +8,12 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers.Builders; using API.Services; @@ -54,22 +57,23 @@ public class SeriesServiceTests : AbstractDbTest var locService = new LocalizationService(ds, new MockHostingEnvironment(), Substitute.For(), Substitute.For()); - _seriesService = new SeriesService(_unitOfWork, Substitute.For(), + _seriesService = new SeriesService(UnitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For(), locService); + Substitute.For(), locService, Substitute.For()); } + #region Setup protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); - _context.AppUserRating.RemoveRange(_context.AppUserRating.ToList()); - _context.Genre.RemoveRange(_context.Genre.ToList()); - _context.CollectionTag.RemoveRange(_context.CollectionTag.ToList()); - _context.Person.RemoveRange(_context.Person.ToList()); - _context.Library.RemoveRange(_context.Library.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); + Context.AppUserRating.RemoveRange(Context.AppUserRating.ToList()); + Context.Genre.RemoveRange(Context.Genre.ToList()); + Context.CollectionTag.RemoveRange(Context.CollectionTag.ToList()); + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } private static UpdateRelatedSeriesDto CreateRelationsDto(Series series) @@ -102,7 +106,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -123,7 +127,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var expectedRanges = new[] {"Omake", "Something SP02"}; @@ -138,7 +142,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -159,7 +163,7 @@ public class SeriesServiceTests : AbstractDbTest .Build() ); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Chapters); @@ -175,7 +179,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -193,7 +197,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Chapters); @@ -209,7 +213,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) @@ -226,7 +230,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Chapters); @@ -245,7 +249,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -260,7 +264,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Volumes); @@ -274,7 +278,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -291,7 +295,7 @@ public class SeriesServiceTests : AbstractDbTest - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Volumes); @@ -311,7 +315,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -329,7 +333,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.Equal("Volume 1", detail.Volumes.ElementAt(0).Name); @@ -346,7 +350,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -370,7 +374,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -397,7 +401,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Comic) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Comic) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -421,7 +425,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -447,7 +451,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.ComicVine) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.ComicVine) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -471,7 +475,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -497,7 +501,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -519,7 +523,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -545,7 +549,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.LightNovel) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.LightNovel) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -567,7 +571,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -587,164 +591,6 @@ public class SeriesServiceTests : AbstractDbTest - #endregion - - - #region UpdateRating - - [Fact] - public async Task UpdateRating_ShouldSetRating() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) - .WithSeries(new SeriesBuilder("Test") - - .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) - .Build()) - .Build()) - .Build()); - - - await _context.SaveChangesAsync(); - - - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - - JobStorage.Current = new InMemoryStorage(); - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 1, - UserRating = 3, - }); - - Assert.True(result); - - var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))! - .Ratings; - Assert.NotEmpty(ratings); - Assert.Equal(3, ratings.First().Rating); - } - - [Fact] - public async Task UpdateRating_ShouldUpdateExistingRating() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) - .WithSeries(new SeriesBuilder("Test") - - .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) - .Build()) - .Build()) - .Build()); - - - await _context.SaveChangesAsync(); - - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 1, - UserRating = 3, - }); - - Assert.True(result); - - JobStorage.Current = new InMemoryStorage(); - var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings)) - .Ratings; - Assert.NotEmpty(ratings); - Assert.Equal(3, ratings.First().Rating); - - // Update the DB again - - var result2 = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 1, - UserRating = 5, - }); - - Assert.True(result2); - - var ratings2 = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings)) - .Ratings; - Assert.NotEmpty(ratings2); - Assert.True(ratings2.Count == 1); - Assert.Equal(5, ratings2.First().Rating); - } - - [Fact] - public async Task UpdateRating_ShouldClampRatingAt5() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) - .WithSeries(new SeriesBuilder("Test") - - .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) - .Build()) - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 1, - UserRating = 10, - }); - - Assert.True(result); - - JobStorage.Current = new InMemoryStorage(); - var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", - AppUserIncludes.Ratings)!) - .Ratings; - Assert.NotEmpty(ratings); - Assert.Equal(5, ratings.First().Rating); - } - - [Fact] - public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) - .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) - .WithSeries(new SeriesBuilder("Test") - - .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) - .Build()) - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 2, - UserRating = 5, - }); - - Assert.False(result); - - var ratings = user.Ratings; - Assert.Empty(ratings); - } - #endregion #region UpdateSeriesMetadata @@ -757,8 +603,8 @@ public class SeriesServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - _context.Series.Add(s); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -772,7 +618,7 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); @@ -789,10 +635,10 @@ public class SeriesServiceTests : AbstractDbTest var g = new GenreBuilder("Existing Genre").Build(); s.Metadata.Genres = new List {g}; - _context.Series.Add(s); + Context.Series.Add(s); - _context.Genre.Add(g); - await _context.SaveChangesAsync(); + Context.Genre.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -806,7 +652,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.Genres.Select(g1 => g1.Title).All(g2 => g2 == "New Genre".SentenceCase())); Assert.False(series.Metadata.GenresLocked); // GenreLocked is false unless the UI Explicitly says it should be locked @@ -817,7 +664,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); var g = new PersonBuilder("Existing Person").Build(); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var s = new SeriesBuilder("Test") .WithMetadata(new SeriesMetadataBuilder() @@ -827,10 +674,10 @@ public class SeriesServiceTests : AbstractDbTest s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - _context.Series.Add(s); + Context.Series.Add(s); - _context.Person.Add(g); - await _context.SaveChangesAsync(); + Context.Person.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -844,7 +691,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.People.Select(g => g.Person.Name).All(personName => personName == "Existing Person")); Assert.False(series.Metadata.PublisherLocked); // PublisherLocked is false unless the UI Explicitly says it should be locked @@ -866,10 +714,10 @@ public class SeriesServiceTests : AbstractDbTest new SeriesMetadataPeople() {Person = new PersonBuilder("Existing Publisher 2").Build(), Role = PersonRole.Publisher} }; - _context.Series.Add(s); + Context.Series.Add(s); - _context.Person.Add(g); - await _context.SaveChangesAsync(); + Context.Person.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -884,7 +732,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.People.Select(g => g.Person.Name).All(personName => personName == "Existing Person")); Assert.True(series.Metadata.PublisherLocked); @@ -915,9 +764,9 @@ public class SeriesServiceTests : AbstractDbTest new SeriesMetadataPeople { Person = new PersonBuilder("Existing Publisher 2").Build(), Role = PersonRole.Publisher } }; - _context.Series.Add(series); - _context.Person.Add(existingPerson); - await _context.SaveChangesAsync(); + Context.Series.Add(series); + Context.Person.Add(existingPerson); + await Context.SaveChangesAsync(); // Act: Update series metadata, attempting to update the writer to "Existing Writer" var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto @@ -934,7 +783,7 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); // Reload the series from the database - var updatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id); + var updatedSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id); Assert.NotNull(updatedSeries.Metadata); // Assert that the people list still contains the updated person with the new name @@ -956,10 +805,10 @@ public class SeriesServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); var g = new PersonBuilder("Existing Person").Build(); - _context.Series.Add(s); + Context.Series.Add(s); - _context.Person.Add(g); - await _context.SaveChangesAsync(); + Context.Person.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -973,11 +822,65 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.False(series.Metadata.People.Any()); } + /// + /// This emulates the UI operations wrt to locking + /// + [Fact] + public async Task UpdateSeriesMetadata_ShouldRemoveExistingPerson_AfterAdding() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); + var g = new PersonBuilder("Existing Person").Build(); + Context.Series.Add(s); + + Context.Person.Add(g); + await Context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = 1, + Publishers = new List() {new PersonDto() {Name = "Test"}}, + PublisherLocked = true + }, + + }); + + Assert.True(success); + + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); + Assert.NotNull(series.Metadata); + Assert.True(series.Metadata.People.Count != 0); + Assert.True(series.Metadata.PublisherLocked); + + + success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = 1, + Publishers = new List(), + PublisherLocked = false + }, + + }); + + Assert.True(success); + Assert.Empty(series.Metadata.People); + Assert.False(series.Metadata.PublisherLocked); + } + [Fact] public async Task UpdateSeriesMetadata_ShouldLockIfTold() { @@ -989,10 +892,10 @@ public class SeriesServiceTests : AbstractDbTest var g = new GenreBuilder("Existing Genre").Build(); s.Metadata.Genres = new List {g}; s.Metadata.GenresLocked = true; - _context.Series.Add(s); + Context.Series.Add(s); - _context.Genre.Add(g); - await _context.SaveChangesAsync(); + Context.Genre.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1007,7 +910,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.Genres.Select(g => g.Title).All(g => g == "Existing Genre".SentenceCase())); Assert.True(series.Metadata.GenresLocked); @@ -1021,8 +925,8 @@ public class SeriesServiceTests : AbstractDbTest .WithMetadata(new SeriesMetadataBuilder().Build()) .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - _context.Series.Add(s); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1036,7 +940,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Equal(0, series.Metadata.ReleaseYear); Assert.False(series.Metadata.ReleaseYearLocked); @@ -1054,8 +959,8 @@ public class SeriesServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); - _context.Series.Add(s); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1068,7 +973,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); Assert.False(series.Metadata.GenresLocked); // Ensure the lock is not activated unless specified. @@ -1086,9 +992,9 @@ public class SeriesServiceTests : AbstractDbTest var g = new GenreBuilder("Existing Genre").Build(); s.Metadata.Genres = new List { g }; - _context.Series.Add(s); - _context.Genre.Add(g); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + Context.Genre.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1101,7 +1007,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.DoesNotContain("Existing Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); @@ -1119,9 +1026,9 @@ public class SeriesServiceTests : AbstractDbTest var g = new GenreBuilder("Existing Genre").Build(); s.Metadata.Genres = new List { g }; - _context.Series.Add(s); - _context.Genre.Add(g); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + Context.Genre.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1134,7 +1041,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Empty(series.Metadata.Genres); } @@ -1151,8 +1059,8 @@ public class SeriesServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); - _context.Series.Add(s); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1165,7 +1073,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); } @@ -1182,9 +1091,9 @@ public class SeriesServiceTests : AbstractDbTest var t = new TagBuilder("Existing Tag").Build(); s.Metadata.Tags = new List { t }; - _context.Series.Add(s); - _context.Tag.Add(t); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + Context.Tag.Add(t); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1197,7 +1106,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.DoesNotContain("Existing Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); @@ -1215,9 +1125,9 @@ public class SeriesServiceTests : AbstractDbTest var t = new TagBuilder("Existing Tag").Build(); s.Metadata.Tags = new List { t }; - _context.Series.Add(s); - _context.Tag.Add(t); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + Context.Tag.Add(t); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1230,7 +1140,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Empty(series.Metadata.Tags); } @@ -1361,12 +1272,12 @@ public class SeriesServiceTests : AbstractDbTest #endregion - #region SeriesRelation + #region Series Relation [Fact] public async Task UpdateRelatedSeries_ShouldAddAllRelations() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1385,9 +1296,9 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); @@ -1402,7 +1313,7 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateRelatedSeries_ShouldAddPrequelWhenAddingSequel() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1420,15 +1331,16 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); - var series2 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series2 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Sequels.Add(2); await _seriesService.UpdateRelatedSeries(addRelationDto); Assert.NotNull(series1); + Assert.NotNull(series2); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); Assert.Equal(1, series2.Relations.Single(s => s.TargetSeriesId == 1).TargetSeriesId); } @@ -1437,7 +1349,7 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateRelatedSeries_DeleteAllRelations() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1456,9 +1368,9 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); @@ -1471,8 +1383,9 @@ public class SeriesServiceTests : AbstractDbTest // Remove relations var removeRelationDto = CreateRelationsDto(series1); await _seriesService.UpdateRelatedSeries(removeRelationDto); - Assert.Empty(series1.Relations.Where(s => s.TargetSeriesId == 1)); - Assert.Empty(series1.Relations.Where(s => s.TargetSeriesId == 2)); + Assert.NotNull(series1); + Assert.DoesNotContain(series1.Relations, s => s.TargetSeriesId == 1); + Assert.DoesNotContain(series1.Relations, s => s.TargetSeriesId == 2); } @@ -1480,7 +1393,7 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateRelatedSeries_DeleteTargetSeries_ShouldSucceed() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1498,19 +1411,21 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); await _seriesService.UpdateRelatedSeries(addRelationDto); + + Assert.NotNull(series1); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); - _context.Series.Remove(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2)); + Context.Series.Remove(await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2)); try { - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } catch (Exception) { @@ -1518,14 +1433,14 @@ public class SeriesServiceTests : AbstractDbTest } // Remove relations - Assert.Empty((await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related)).Relations); + Assert.Empty((await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related)).Relations); } [Fact] public async Task UpdateRelatedSeries_DeleteSourceSeries_ShouldSucceed() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1543,9 +1458,9 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); @@ -1553,12 +1468,12 @@ public class SeriesServiceTests : AbstractDbTest Assert.NotNull(series1); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); - var seriesToRemove = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var seriesToRemove = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); Assert.NotNull(seriesToRemove); - _context.Series.Remove(seriesToRemove); + Context.Series.Remove(seriesToRemove); try { - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } catch (Exception) { @@ -1566,14 +1481,14 @@ public class SeriesServiceTests : AbstractDbTest } // Remove relations - Assert.Empty((await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related)).Relations); + Assert.Empty((await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related)).Relations); } [Fact] public async Task UpdateRelatedSeries_ShouldNotAllowDuplicates() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1591,9 +1506,9 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); var relation = new SeriesRelation { Series = series1, @@ -1618,7 +1533,7 @@ public class SeriesServiceTests : AbstractDbTest public async Task GetRelatedSeries_EditionPrequelSequel_ShouldNotHaveParent() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1638,8 +1553,8 @@ public class SeriesServiceTests : AbstractDbTest new SeriesBuilder("Test Series Adaption").Build(), } }); - await _context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + await Context.SaveChangesAsync(); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Editions.Add(2); @@ -1665,30 +1580,30 @@ public class SeriesServiceTests : AbstractDbTest .WithSeries(new SeriesBuilder("Test Series Sequels").Build()) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib); + Context.Library.Add(lib); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); addRelationDto.Sequels.Add(3); await _seriesService.UpdateRelatedSeries(addRelationDto); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(lib.Id); - _unitOfWork.LibraryRepository.Delete(library); + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(lib.Id); + UnitOfWork.LibraryRepository.Delete(library); try { - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); } catch (Exception) { Assert.False(true); } - Assert.Null(await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1)); + Assert.Null(await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1)); } [Fact] @@ -1709,7 +1624,7 @@ public class SeriesServiceTests : AbstractDbTest .WithSeries(new SeriesBuilder("Test Series Sequels").Build()) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib1); + Context.Library.Add(lib1); var lib2 = new LibraryBuilder("Test LIb 2", LibraryType.Book) .WithSeries(new SeriesBuilder("Test Series 2").Build()) @@ -1717,29 +1632,29 @@ public class SeriesServiceTests : AbstractDbTest .WithSeries(new SeriesBuilder("Test Series Prequels 3").Build())// TODO: Is this a bug .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib2); + Context.Library.Add(lib2); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(4); // cross library link await _seriesService.UpdateRelatedSeries(addRelationDto); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(lib1.Id, LibraryIncludes.Series); - _unitOfWork.LibraryRepository.Delete(library); + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(lib1.Id, LibraryIncludes.Series); + UnitOfWork.LibraryRepository.Delete(library); try { - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); } catch (Exception) { Assert.False(true); } - Assert.Null(await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1)); + Assert.Null(await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1)); } #endregion @@ -1761,13 +1676,13 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty) .WithLocale("en") .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); Assert.Equal(expected, await _seriesService.FormatChapterName(1, libraryType, withHash)); } @@ -1932,18 +1847,18 @@ public class SeriesServiceTests : AbstractDbTest .WithSeries(new SeriesBuilder("Test Series Sequels").Build()) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib1); + Context.Library.Add(lib1); var lib2 = new LibraryBuilder("Test LIb 2", LibraryType.Book) .WithSeries(new SeriesBuilder("Test Series 2").Build()) .WithSeries(new SeriesBuilder("Test Series Prequels 2").Build()) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib2); + Context.Library.Add(lib2); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related | SeriesIncludes.ExternalRatings); // Add relations var addRelationDto = CreateRelationsDto(series1); @@ -1989,12 +1904,12 @@ public class SeriesServiceTests : AbstractDbTest } }; - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Ensure we can delete the series Assert.True(await _seriesService.DeleteMultipleSeries(new[] {1, 2})); - Assert.Null(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1)); - Assert.Null(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2)); + Assert.Null(await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1)); + Assert.Null(await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2)); } #endregion @@ -2006,7 +1921,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -2019,7 +1934,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); @@ -2031,7 +1946,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") .WithPublicationStatus(PublicationStatus.Completed) @@ -2044,7 +1959,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); @@ -2056,7 +1971,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -2068,7 +1983,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); Assert.NotNull(nextChapter); @@ -2080,9 +1995,9 @@ public class SeriesServiceTests : AbstractDbTest public async Task GetEstimatedChapterCreationDate_NextChapter_ChaptersMonthApart() { await ResetDb(); - var now = DateTime.Parse("2021-01-01"); // 10/31/2024 can trigger an edge case bug + var now = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture); // 10/31/2024 can trigger an edge case bug - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") .WithPublicationStatus(PublicationStatus.OnGoing) @@ -2096,7 +2011,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); Assert.NotNull(nextChapter); diff --git a/API.Tests/Services/SettingsServiceTests.cs b/API.Tests/Services/SettingsServiceTests.cs new file mode 100644 index 000000000..a3c6b67b8 --- /dev/null +++ b/API.Tests/Services/SettingsServiceTests.cs @@ -0,0 +1,292 @@ +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.Entities; +using API.Entities.Enums; +using API.Entities.MetadataMatching; +using API.Services; +using API.Services.Tasks.Scanner; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class SettingsServiceTests +{ + private readonly ISettingsService _settingsService; + private readonly IUnitOfWork _mockUnitOfWork; + + public SettingsServiceTests() + { + var ds = new DirectoryService(Substitute.For>(), new FileSystem()); + + _mockUnitOfWork = Substitute.For(); + _settingsService = new SettingsService(_mockUnitOfWork, ds, + Substitute.For(), Substitute.For(), + Substitute.For>()); + } + + #region UpdateMetadataSettings + + [Fact] + public async Task UpdateMetadataSettings_ShouldUpdateExistingSettings() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + Enabled = false, + EnableSummary = false, + EnableLocalizedName = false, + EnablePublicationStatus = false, + EnableRelationships = false, + EnablePeople = false, + EnableStartDate = false, + EnableGenres = false, + EnableTags = false, + FirstLastPeopleNaming = false, + EnableCoverImage = false, + AgeRatingMappings = new Dictionary(), + Blacklist = [], + Whitelist = [], + Overrides = [], + PersonRoles = [], + FieldMappings = [] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + Enabled = true, + EnableSummary = true, + EnableLocalizedName = true, + EnablePublicationStatus = true, + EnableRelationships = true, + EnablePeople = true, + EnableStartDate = true, + EnableGenres = true, + EnableTags = true, + FirstLastPeopleNaming = true, + EnableCoverImage = true, + AgeRatingMappings = new Dictionary { { "Adult", AgeRating.R18Plus } }, + Blacklist = ["blacklisted-tag"], + Whitelist = ["whitelisted-tag"], + Overrides = [MetadataSettingField.Summary], + PersonRoles = [PersonRole.Writer], + FieldMappings = + [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Genre, + DestinationType = MetadataFieldType.Tag, + SourceValue = "Action", + DestinationValue = "Fight", + ExcludeFromSource = true + } + ] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + // Verify properties were updated + Assert.True(existingSettings.Enabled); + Assert.True(existingSettings.EnableSummary); + Assert.True(existingSettings.EnableLocalizedName); + Assert.True(existingSettings.EnablePublicationStatus); + Assert.True(existingSettings.EnableRelationships); + Assert.True(existingSettings.EnablePeople); + Assert.True(existingSettings.EnableStartDate); + Assert.True(existingSettings.EnableGenres); + Assert.True(existingSettings.EnableTags); + Assert.True(existingSettings.FirstLastPeopleNaming); + Assert.True(existingSettings.EnableCoverImage); + + // Verify collections were updated + Assert.Single(existingSettings.AgeRatingMappings); + Assert.Equal(AgeRating.R18Plus, existingSettings.AgeRatingMappings["Adult"]); + + Assert.Single(existingSettings.Blacklist); + Assert.Equal("blacklisted-tag", existingSettings.Blacklist[0]); + + Assert.Single(existingSettings.Whitelist); + Assert.Equal("whitelisted-tag", existingSettings.Whitelist[0]); + + Assert.Single(existingSettings.Overrides); + Assert.Equal(MetadataSettingField.Summary, existingSettings.Overrides[0]); + + Assert.Single(existingSettings.PersonRoles); + Assert.Equal(PersonRole.Writer, existingSettings.PersonRoles[0]); + + Assert.Single(existingSettings.FieldMappings); + Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].SourceType); + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].DestinationType); + Assert.Equal("Action", existingSettings.FieldMappings[0].SourceValue); + Assert.Equal("Fight", existingSettings.FieldMappings[0].DestinationValue); + Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); + } + + [Fact] + public async Task UpdateMetadataSettings_WithNullCollections_ShouldUseEmptyCollections() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + FieldMappings = [new MetadataFieldMapping {Id = 1, SourceValue = "OldValue"}] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + AgeRatingMappings = null, + Blacklist = null, + Whitelist = null, + Overrides = null, + PersonRoles = null, + FieldMappings = null + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + Assert.Empty(existingSettings.AgeRatingMappings); + Assert.Empty(existingSettings.Blacklist); + Assert.Empty(existingSettings.Whitelist); + Assert.Empty(existingSettings.Overrides); + Assert.Empty(existingSettings.PersonRoles); + + // Verify existing field mappings were cleared + settingsRepo.Received(1).RemoveRange(Arg.Any>()); + Assert.Empty(existingSettings.FieldMappings); + } + + [Fact] + public async Task UpdateMetadataSettings_WithFieldMappings_ShouldReplaceExistingMappings() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + FieldMappings = + [ + new MetadataFieldMapping + { + Id = 1, + SourceType = MetadataFieldType.Genre, + DestinationType = MetadataFieldType.Genre, + SourceValue = "OldValue", + DestinationValue = "OldDestination", + ExcludeFromSource = false + } + ] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + FieldMappings = + [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + DestinationType = MetadataFieldType.Genre, + SourceValue = "NewValue", + DestinationValue = "NewDestination", + ExcludeFromSource = true + }, + + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + DestinationType = MetadataFieldType.Tag, + SourceValue = "AnotherValue", + DestinationValue = "AnotherDestination", + ExcludeFromSource = false + } + ] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + // Verify existing field mappings were cleared and new ones added + settingsRepo.Received(1).RemoveRange(Arg.Any>()); + Assert.Equal(2, existingSettings.FieldMappings.Count); + + // Verify first mapping + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].SourceType); + Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].DestinationType); + Assert.Equal("NewValue", existingSettings.FieldMappings[0].SourceValue); + Assert.Equal("NewDestination", existingSettings.FieldMappings[0].DestinationValue); + Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); + + // Verify second mapping + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].SourceType); + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].DestinationType); + Assert.Equal("AnotherValue", existingSettings.FieldMappings[1].SourceValue); + Assert.Equal("AnotherDestination", existingSettings.FieldMappings[1].DestinationValue); + Assert.False(existingSettings.FieldMappings[1].ExcludeFromSource); + } + + [Fact] + public async Task UpdateMetadataSettings_WithBlacklistWhitelist_ShouldNormalizeAndDeduplicateEntries() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + Blacklist = [], + Whitelist = [] + }; + + // We need to mock the repository and provide a custom implementation for ToNormalized + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + // Include duplicates with different casing and whitespace + Blacklist = ["tag1", "Tag1", " tag2 ", "", " ", "tag3"], + Whitelist = ["allowed1", "Allowed1", " allowed2 ", "", "allowed3"] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + Assert.Equal(3, existingSettings.Blacklist.Count); + Assert.Equal(3, existingSettings.Whitelist.Count); + } + + #endregion +} diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs index 463d49df4..3893af1fb 100644 --- a/API.Tests/Services/SiteThemeServiceTests.cs +++ b/API.Tests/Services/SiteThemeServiceTests.cs @@ -31,24 +31,24 @@ public abstract class SiteThemeServiceTest : AbstractDbTest protected override async Task ResetDb() { - _context.SiteTheme.RemoveRange(_context.SiteTheme); - await _context.SaveChangesAsync(); + Context.SiteTheme.RemoveRange(Context.SiteTheme); + await Context.SaveChangesAsync(); // Recreate defaults - await Seed.SeedThemes(_context); + await Seed.SeedThemes(Context); } [Fact] public async Task UpdateDefault_ShouldThrowOnInvalidId() { await ResetDb(); - _testOutputHelper.WriteLine($"[UpdateDefault_ShouldThrowOnInvalidId] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); + _testOutputHelper.WriteLine($"[UpdateDefault_ShouldThrowOnInvalidId] All Themes: {(await UnitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + var siteThemeService = new ThemeService(ds, UnitOfWork, _messageHub, Substitute.For(), Substitute.For>(), Substitute.For()); - _context.SiteTheme.Add(new SiteTheme() + Context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), @@ -56,7 +56,7 @@ public abstract class SiteThemeServiceTest : AbstractDbTest FileName = "custom.css", IsDefault = false }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => siteThemeService.UpdateDefault(10)); Assert.Equal("Theme file missing or invalid", ex.Message); @@ -68,14 +68,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest public async Task GetContent_ShouldReturnContent() { await ResetDb(); - _testOutputHelper.WriteLine($"[GetContent_ShouldReturnContent] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); + _testOutputHelper.WriteLine($"[GetContent_ShouldReturnContent] All Themes: {(await UnitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + var siteThemeService = new ThemeService(ds, UnitOfWork, _messageHub, Substitute.For(), Substitute.For>(), Substitute.For()); - _context.SiteTheme.Add(new SiteTheme() + Context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), @@ -83,9 +83,9 @@ public abstract class SiteThemeServiceTest : AbstractDbTest FileName = "custom.css", IsDefault = false }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var content = await siteThemeService.GetContent((await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id); + var content = await siteThemeService.GetContent((await UnitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id); Assert.NotNull(content); Assert.NotEmpty(content); Assert.Equal("123", content); @@ -95,14 +95,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest public async Task UpdateDefault_ShouldHaveOneDefault() { await ResetDb(); - _testOutputHelper.WriteLine($"[UpdateDefault_ShouldHaveOneDefault] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); + _testOutputHelper.WriteLine($"[UpdateDefault_ShouldHaveOneDefault] All Themes: {(await UnitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + var siteThemeService = new ThemeService(ds, UnitOfWork, _messageHub, Substitute.For(), Substitute.For>(), Substitute.For()); - _context.SiteTheme.Add(new SiteTheme() + Context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), @@ -110,16 +110,16 @@ public abstract class SiteThemeServiceTest : AbstractDbTest FileName = "custom.css", IsDefault = false }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var customTheme = (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")); + var customTheme = (await UnitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")); Assert.NotNull(customTheme); await siteThemeService.UpdateDefault(customTheme.Id); - Assert.Equal(customTheme.Id, (await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Id); + Assert.Equal(customTheme.Id, (await UnitOfWork.SiteThemeRepository.GetDefaultTheme()).Id); } } diff --git a/API.Tests/Services/TachiyomiServiceTests.cs b/API.Tests/Services/TachiyomiServiceTests.cs index 1e5127865..17e26139c 100644 --- a/API.Tests/Services/TachiyomiServiceTests.cs +++ b/API.Tests/Services/TachiyomiServiceTests.cs @@ -1,7 +1,5 @@ -using API.Extensions; -using API.Helpers.Builders; +using API.Helpers.Builders; using API.Services.Plus; -using API.Services.Tasks; namespace API.Tests.Services; using System.Collections.Generic; @@ -16,7 +14,6 @@ using API.Entities.Enums; using API.Helpers; using API.Services; using SignalR; -using Helpers; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; diff --git a/API.Tests/Services/Test Data/BookService/Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf b/API.Tests/Services/Test Data/BookService/Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf new file mode 100644 index 000000000..9fe4811a7 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf differ diff --git a/API.Tests/Services/Test Data/BookService/Rollo at Work SP01.pdf b/API.Tests/Services/Test Data/BookService/Rollo at Work SP01.pdf new file mode 100644 index 000000000..0e0ffa8c7 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/Rollo at Work SP01.pdf differ diff --git a/API.Tests/Services/Test Data/BookService/encrypted.pdf b/API.Tests/Services/Test Data/BookService/encrypted.pdf new file mode 100644 index 000000000..64249b728 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/encrypted.pdf differ diff --git a/API.Tests/Services/Test Data/BookService/indirect.pdf b/API.Tests/Services/Test Data/BookService/indirect.pdf new file mode 100644 index 000000000..11ecdcb76 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/indirect.pdf differ diff --git a/API.Tests/Services/Test Data/CoverDbService/Existing/01.webp b/API.Tests/Services/Test Data/CoverDbService/Existing/01.webp new file mode 100644 index 000000000..0b46b66d2 Binary files /dev/null and b/API.Tests/Services/Test Data/CoverDbService/Existing/01.webp differ diff --git a/API.Tests/Services/Test Data/CoverDbService/Favicons/anilist.co.webp b/API.Tests/Services/Test Data/CoverDbService/Favicons/anilist.co.webp new file mode 100644 index 000000000..475824863 Binary files /dev/null and b/API.Tests/Services/Test Data/CoverDbService/Favicons/anilist.co.webp differ diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json new file mode 100644 index 000000000..791dcdc44 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Delete Series In UI - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Delete Series In UI - Manga.json new file mode 100644 index 000000000..791dcdc44 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Delete Series In UI - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json new file mode 100644 index 000000000..fe931174e --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json @@ -0,0 +1,5 @@ +[ + "Antarctic Press/Plush/Plush v01.cbz", + "Antarctic Press/Plush/Plush v02.cbz", + "Antarctic Press/Plush/Extra/Plush v03.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json new file mode 100644 index 000000000..06ed0f1f9 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json @@ -0,0 +1,6 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/My Dress-Up Darling v02.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 10.cbz", + "My Dress-Up Darling/Specials/My Dress-Up Darling - Omakes SP01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json index d864da284..3fa9eebf7 100644 --- a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json @@ -1,5 +1,5 @@ [ - "Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", - "Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", - "Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz" -] \ No newline at end of file + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json new file mode 100644 index 000000000..feb1fd99f --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json @@ -0,0 +1,3 @@ +[ + "Immoral Guild/Futoku no Guild v01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json new file mode 100644 index 000000000..c9d4b14b6 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series removed when no other changes are made - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series removed when no other changes are made - Manga.json new file mode 100644 index 000000000..c6ea3bc88 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series removed when no other changes are made - Manga.json @@ -0,0 +1,6 @@ +[ + "Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 2.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json new file mode 100644 index 000000000..d6e91183b --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json @@ -0,0 +1,5 @@ +[ + "Immoral Guild/Immoral Guild v01.cbz", + "Immoral Guild/Immoral Guild v02.cbz", + "Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Prefix - Book.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Prefix - Book.json new file mode 100644 index 000000000..fc2bee18c --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Prefix - Book.json @@ -0,0 +1,3 @@ +[ + "The Avengers/The Avengers vol 1.pdf" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Sort Order - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Sort Order - Manga.json new file mode 100644 index 000000000..0b2dd765d --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Sort Order - Manga.json @@ -0,0 +1,5 @@ +[ + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 1-3.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 4.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 5.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolder always scanning fix publisher layout - Comic.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolder always scanning fix publisher layout - Comic.json new file mode 100644 index 000000000..4574ddb4e --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolder always scanning fix publisher layout - Comic.json @@ -0,0 +1,8 @@ +[ + "VizMedia/Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 1.cbz", + "VizMedia/Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 2.cbz", + "VizMedia/Seraph of the End/Seraph of the End Vol. 1.cbz", + "YenPress/Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "YenPress/Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "YenPress/The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders always scanning all series changes - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders always scanning all series changes - Manga.json new file mode 100644 index 000000000..3d7c74d5c --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders always scanning all series changes - Manga.json @@ -0,0 +1,11 @@ +[ + "Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 1/Frieren - Beyond Journey's End Ch. 0001.cbz", + "Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 1/Frieren - Beyond Journey's End Ch. 0002.cbz", + "Seraph of the End/Seraph of the End Vol. 1/Seraph of the End Ch. 0001.cbz", + "Spice and Wolf/Spice and Wolf Vol. 1/Spice and Wolf Vol. 1 Ch. 0001.cbz", + "Spice and Wolf/Spice and Wolf Vol. 1/Spice and Wolf Vol. 1 Ch. 0002.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2/Spice and Wolf Vol. 2 Ch. 0003.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1/The Executioner and Her Way of Life Vol. 1 Ch. 0001.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 2/The Executioner and Her Way of Life Vol. 2 Ch. 0003.cbz" +] + diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root (2) - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root (2) - Manga.json new file mode 100644 index 000000000..103ea421a --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root (2) - Manga.json @@ -0,0 +1,6 @@ +[ + "Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0011.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0012.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root - Manga.json new file mode 100644 index 000000000..103ea421a --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root - Manga.json @@ -0,0 +1,6 @@ +[ + "Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0011.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0012.cbz" +] diff --git a/API.Tests/Services/VersionUpdaterServiceTests.cs b/API.Tests/Services/VersionUpdaterServiceTests.cs new file mode 100644 index 000000000..8be8f4aee --- /dev/null +++ b/API.Tests/Services/VersionUpdaterServiceTests.cs @@ -0,0 +1,446 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using API.DTOs.Update; +using API.Services; +using API.Services.Tasks; +using API.SignalR; +using Flurl.Http.Testing; +using Kavita.Common.EnvironmentInfo; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class VersionUpdaterServiceTests : IDisposable +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IEventHub _eventHub = Substitute.For(); + private readonly IDirectoryService _directoryService = Substitute.For(); + private readonly VersionUpdaterService _service; + private readonly string _tempPath; + private readonly HttpTest _httpTest; + + public VersionUpdaterServiceTests() + { + // Create temp directory for cache + _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempPath); + _directoryService.LongTermCacheDirectory.Returns(_tempPath); + + _service = new VersionUpdaterService(_logger, _eventHub, _directoryService); + + // Setup HTTP testing + _httpTest = new HttpTest(); + + // Mock BuildInfo.Version for consistent testing + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.5.0.0")); + } + + public void Dispose() + { + _httpTest.Dispose(); + + // Cleanup temp directory + if (Directory.Exists(_tempPath)) + { + Directory.Delete(_tempPath, true); + } + + // Reset BuildInfo.Version + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, null); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task CheckForUpdate_ShouldReturnNull_WhenGithubApiReturnsNull() + { + + _httpTest.RespondWith("null"); + + + var result = await _service.CheckForUpdate(); + + + Assert.Null(result); + } + + // Depends on BuildInfo.CurrentVersion + //[Fact] + public async Task CheckForUpdate_ShouldReturnUpdateNotification_WhenNewVersionIsAvailable() + { + + var githubResponse = new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature 1\n- Feature 2\n# Fixed\n- Bug 1\n- Bug 2", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + + var result = await _service.CheckForUpdate(); + + + Assert.NotNull(result); + Assert.Equal("0.6.0", result.UpdateVersion); + Assert.Equal("0.5.0.0", result.CurrentVersion); + Assert.True(result.IsReleaseNewer); + Assert.Equal(2, result.Added.Count); + Assert.Equal(2, result.Fixed.Count); + } + + //[Fact] + public async Task CheckForUpdate_ShouldDetectEqualVersion() + { + // I can't figure this out + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.5.0.0")); + + + var githubResponse = new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature 1", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + + var result = await _service.CheckForUpdate(); + + + Assert.NotNull(result); + Assert.True(result.IsReleaseEqual); + Assert.False(result.IsReleaseNewer); + } + + + //[Fact] + public async Task PushUpdate_ShouldSendUpdateEvent_WhenNewerVersionAvailable() + { + + var update = new UpdateNotificationDto + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null, + PublishDate = null + }; + + + await _service.PushUpdate(update); + + + await _eventHub.Received(1).SendMessageAsync( + Arg.Is(MessageFactory.UpdateAvailable), + Arg.Any(), + Arg.Is(true) + ); + } + + [Fact] + public async Task PushUpdate_ShouldNotSendUpdateEvent_WhenVersionIsEqual() + { + + var update = new UpdateNotificationDto + { + UpdateVersion = "0.5.0.0", + CurrentVersion = "0.5.0.0", + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null, + PublishDate = null + }; + + + await _service.PushUpdate(update); + + + await _eventHub.DidNotReceive().SendMessageAsync( + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task GetAllReleases_ShouldReturnReleases_LimitedByCount() + { + + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature C", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.AddDays(-20).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + + var result = await _service.GetAllReleases(2); + + + Assert.Equal(2, result.Count); + Assert.Equal("0.7.0.0", result[0].UpdateVersion); + Assert.Equal("0.6.0", result[1].UpdateVersion); + } + + [Fact] + public async Task GetAllReleases_ShouldUseCachedData_WhenCacheIsValid() + { + + var releases = new List + { + new() + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-10) + .ToString("o"), + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null + } + }; + releases.Add(new() + { + UpdateVersion = "0.7.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-1) + .ToString("o"), + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null + }); + + // Create cache file + var cacheFilePath = Path.Combine(_tempPath, "github_releases_cache.json"); + await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases)); + File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow); // Ensure it's fresh + + + var result = await _service.GetAllReleases(); + + + Assert.Equal(2, result.Count); + Assert.Empty(_httpTest.CallLog); // No HTTP calls made + } + + [Fact] + public async Task GetAllReleases_ShouldFetchNewData_WhenCacheIsExpired() + { + + var releases = new List + { + new() + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-10) + .ToString("o"), + UpdateBody = null, + UpdateTitle = null, + UpdateUrl = null + } + }; + + // Create expired cache file + var cacheFilePath = Path.Combine(_tempPath, "github_releases_cache.json"); + await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases)); + File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow.AddHours(-2)); // Expired (older than 1 hour) + + // Setup HTTP response for new fetch + var newReleases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.ToString("o") + } + }; + + _httpTest.RespondWithJson(newReleases); + + + var result = await _service.GetAllReleases(); + + + Assert.Single(result); + Assert.Equal("0.7.0.0", result[0].UpdateVersion); + Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made + } + + public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount() + { + + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature C", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.AddDays(-20).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + + var result = await _service.GetNumberOfReleasesBehind(); + + + Assert.Equal(2 + 1, result); // Behind 0.7.0 and 0.6.0 - We have to add 1 because the current release is > 0.7.0 + } + + public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies() + { + + var releases = new List + { + new + { + tag_name = "v0.7.1", + name = "Release 0.7.1", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.1", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + }; + + _httpTest.RespondWithJson(releases); + + + var result = await _service.GetNumberOfReleasesBehind(); + + + Assert.Equal(2, result); // We have to add 1 because the current release is > 0.7.0 + } + + [Fact] + public async Task ParseReleaseBody_ShouldExtractSections() + { + + var githubResponse = new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "This is a great release with many improvements!\n\n# Added\n- Feature 1\n- Feature 2\n# Fixed\n- Bug 1\n- Bug 2\n# Changed\n- Change 1\n# Developer\n- Dev note 1", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + + var result = await _service.CheckForUpdate(); + + + Assert.NotNull(result); + Assert.Equal(2, result.Added.Count); + Assert.Equal(2, result.Fixed.Count); + Assert.Equal(1, result.Changed.Count); + Assert.Equal(1, result.Developer.Count); + Assert.Contains("This is a great release", result.BlogPart); + } + + [Fact] + public async Task GetAllReleases_ShouldHandleNightlyBuilds() + { + + // Set BuildInfo.Version to a nightly build version + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.7.1.0")); + + // Mock regular releases + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + // Mock commit info for develop branch + _httpTest.RespondWithJson(new List()); + + + var result = await _service.GetAllReleases(); + + + Assert.NotNull(result); + Assert.True(result[0].IsOnNightlyInRelease); + } +} diff --git a/API.Tests/Services/WordCountAnalysisTests.cs b/API.Tests/Services/WordCountAnalysisTests.cs index ae17172b2..57c6ec7f6 100644 --- a/API.Tests/Services/WordCountAnalysisTests.cs +++ b/API.Tests/Services/WordCountAnalysisTests.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; @@ -11,10 +10,8 @@ using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; -using API.Services.Tasks; using API.Services.Tasks.Metadata; using API.SignalR; -using API.Tests.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -29,9 +26,10 @@ public class WordCountAnalysisTests : AbstractDbTest private const long MinHoursToRead = 1; private const float AvgHoursToRead = 1.66954792f; private const long MaxHoursToRead = 3; - public WordCountAnalysisTests() : base() + + public WordCountAnalysisTests() { - _readerService = new ReaderService(_unitOfWork, Substitute.For>(), + _readerService = new ReaderService(UnitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For()); @@ -39,9 +37,9 @@ public class WordCountAnalysisTests : AbstractDbTest protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } [Fact] @@ -59,7 +57,7 @@ public class WordCountAnalysisTests : AbstractDbTest MangaFormat.Epub).Build()) .Build(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithSeries(series) .Build()); @@ -70,11 +68,11 @@ public class WordCountAnalysisTests : AbstractDbTest .Build(), }; - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var cacheService = new CacheHelper(new FileService()); - var service = new WordCountAnalyzerService(Substitute.For>(), _unitOfWork, + var service = new WordCountAnalyzerService(Substitute.For>(), UnitOfWork, Substitute.For(), cacheService, _readerService, Substitute.For()); @@ -86,7 +84,7 @@ public class WordCountAnalysisTests : AbstractDbTest Assert.Equal(MaxHoursToRead, series.MaxHoursToRead); // Validate the Chapter gets updated correctly - var volume = series.Volumes.First(); + var volume = series.Volumes[0]; Assert.Equal(WordCount, volume.WordCount); Assert.Equal(MinHoursToRead, volume.MinHoursToRead); Assert.Equal(AvgHoursToRead, volume.AvgHoursToRead); @@ -117,16 +115,16 @@ public class WordCountAnalysisTests : AbstractDbTest .Build()) .Build(); - _context.Library.Add(new LibraryBuilder("Test", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test", LibraryType.Book) .WithSeries(series) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var cacheService = new CacheHelper(new FileService()); - var service = new WordCountAnalyzerService(Substitute.For>(), _unitOfWork, + var service = new WordCountAnalyzerService(Substitute.For>(), UnitOfWork, Substitute.For(), cacheService, _readerService, Substitute.For()); await service.ScanSeries(1, 1); @@ -142,21 +140,21 @@ public class WordCountAnalysisTests : AbstractDbTest .WithChapter(chapter2) .Build()); - series.Volumes.First().Chapters.Add(chapter2); - await _unitOfWork.CommitAsync(); + series.Volumes[0].Chapters.Add(chapter2); + await UnitOfWork.CommitAsync(); await service.ScanSeries(1, 1); Assert.Equal(WordCount * 2L, series.WordCount); Assert.Equal(MinHoursToRead * 2, series.MinHoursToRead); - var firstVolume = series.Volumes.ElementAt(0); + var firstVolume = series.Volumes[0]; Assert.Equal(WordCount, firstVolume.WordCount); Assert.Equal(MinHoursToRead, firstVolume.MinHoursToRead); Assert.True(series.AvgHoursToRead.Is(AvgHoursToRead * 2)); Assert.Equal(MaxHoursToRead, firstVolume.MaxHoursToRead); - var secondVolume = series.Volumes.ElementAt(1); + var secondVolume = series.Volumes[1]; Assert.Equal(WordCount, secondVolume.WordCount); Assert.Equal(MinHoursToRead, secondVolume.MinHoursToRead); Assert.Equal(AvgHoursToRead, secondVolume.AvgHoursToRead); diff --git a/API/API.csproj b/API/API.csproj index 0c9aae840..a7d1177dc 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -2,7 +2,7 @@ Default - net8.0 + net9.0 true Linux true @@ -12,10 +12,6 @@ latestmajor - - - - false @@ -54,9 +50,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -64,49 +60,48 @@ - - - + + + - + - - - - - - - + + + + + + + - - - - - + + + + - - + + - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - + + + + + + + @@ -116,16 +111,16 @@ - - - - + + + + @@ -143,6 +138,7 @@ + @@ -196,6 +192,10 @@ Always + + Always + + diff --git a/API/Assets/anilist-no-image-placeholder.jpg b/API/Assets/anilist-no-image-placeholder.jpg new file mode 100644 index 000000000..54c1066b6 Binary files /dev/null and b/API/Assets/anilist-no-image-placeholder.jpg differ diff --git a/API/Constants/CacheProfiles.cs b/API/Constants/CacheProfiles.cs index ee2cd204e..afc82f19c 100644 --- a/API/Constants/CacheProfiles.cs +++ b/API/Constants/CacheProfiles.cs @@ -8,10 +8,18 @@ public static class EasyCacheProfiles public const string RevokedJwt = "revokedJWT"; public const string Favicon = "favicon"; /// + /// Images for Publishers + /// + public const string Publisher = "publisherImages"; + /// /// If a user's license is valid /// public const string License = "license"; /// + /// License Information + /// + public const string LicenseInfo = "licenseInfo"; + /// /// Cache the libraries on the server /// public const string Library = "library"; @@ -19,4 +27,12 @@ public static class EasyCacheProfiles /// External Series metadata for Kavita+ recommendation /// public const string KavitaPlusExternalSeries = "kavita+externalSeries"; + /// + /// Match Series metadata for Kavita+ metadata download + /// + public const string KavitaPlusMatchSeries = "kavita+matchSeries"; + /// + /// All Locales on the Server + /// + public const string LocaleOptions = "locales"; } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 0b47aa526..d8b9164af 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -15,6 +16,7 @@ using API.Errors; using API.Extensions; using API.Helpers.Builders; using API.Services; +using API.Services.Plus; using API.SignalR; using AutoMapper; using Hangfire; @@ -136,6 +138,12 @@ public class AccountController : BaseApiController return BadRequest(usernameValidation); } + // If Email is empty, default to the username + if (string.IsNullOrEmpty(registerDto.Email)) + { + registerDto.Email = registerDto.Username; + } + var user = new AppUserBuilder(registerDto.Username, registerDto.Email, await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build(); @@ -145,6 +153,9 @@ public class AccountController : BaseApiController // Assign default streams AddDefaultStreamsToUser(user); + // Assign default reading profile + await AddDefaultReadingProfileToUser(user); + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen")); if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Get("en", "validate-email", token)); @@ -350,10 +361,11 @@ public class AccountController : BaseApiController /// /// Returns just if the email was sent or server isn't reachable [HttpPost("update/email")] - public async Task UpdateEmail(UpdateEmailDto? dto) + public async Task> UpdateEmail(UpdateEmailDto? dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) + return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload")); @@ -362,12 +374,13 @@ public class AccountController : BaseApiController // Validate this user's password if (! await _userManager.CheckPasswordAsync(user, dto.Password)) { - _logger.LogCritical("A user tried to change {UserName}'s email, but password didn't validate", user.UserName); + _logger.LogWarning("A user tried to change {UserName}'s email, but password didn't validate", user.UserName); return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); } // Validate no other users exist with this email - if (user.Email!.Equals(dto.Email)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); + if (user.Email!.Equals(dto.Email)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); // Check if email is used by another user var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); @@ -384,8 +397,10 @@ public class AccountController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token")); } + var isValidEmailAddress = _emailService.IsValidEmail(user.Email); var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email); + var shouldEmailUser = serverSettings.IsEmailSetup() || !isValidEmailAddress; + user.EmailConfirmed = !shouldEmailUser; user.ConfirmationToken = token; await _userManager.UpdateAsync(user); @@ -399,7 +414,8 @@ public class AccountController : BaseApiController return Ok(new InviteUserResponse { EmailLink = string.Empty, - EmailSent = false + EmailSent = false, + InvalidEmail = !isValidEmailAddress }); } @@ -407,7 +423,7 @@ public class AccountController : BaseApiController // Send a confirmation email try { - if (!_emailService.IsValidEmail(user.Email)) + if (!isValidEmailAddress) { _logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email); return Ok(new InviteUserResponse @@ -439,7 +455,8 @@ public class AccountController : BaseApiController return Ok(new InviteUserResponse { EmailLink = string.Empty, - EmailSent = true + EmailSent = true, + InvalidEmail = !isValidEmailAddress }); } catch (Exception ex) @@ -457,6 +474,7 @@ public class AccountController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); if (!await _accountService.CanChangeAgeRestriction(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); @@ -494,6 +512,7 @@ public class AccountController : BaseApiController var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (adminUser == null) return Unauthorized(); if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams); if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user")); @@ -593,7 +612,7 @@ public class AccountController : BaseApiController } /// - /// Requests the Invite Url for the UserId. Will return error if user is already validated. + /// Requests the Invite Url for the AppUserId. Will return error if user is already validated. /// /// /// Include the "https://ip:port/" in the generated link @@ -653,6 +672,9 @@ public class AccountController : BaseApiController // Assign default streams AddDefaultStreamsToUser(user); + // Assign default reading profile + await AddDefaultReadingProfileToUser(user); + // Assign Roles var roles = dto.Roles; var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); @@ -763,6 +785,16 @@ public class AccountController : BaseApiController } } + private async Task AddDefaultReadingProfileToUser(AppUser user) + { + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Default Profile") + .WithKind(ReadingProfileKind.Default) + .Build(); + _unitOfWork.AppUserReadingProfileRepository.Add(profile); + await _unitOfWork.CommitAsync(); + } + /// /// Last step in authentication flow, confirms the email token for email /// @@ -911,7 +943,6 @@ public class AccountController : BaseApiController [EnableRateLimiting("Authentication")] public async Task> ForgotPassword([FromQuery] string email) { - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); if (user == null) @@ -1012,6 +1043,8 @@ public class AccountController : BaseApiController await _localizationService.Translate(user.Id, "user-migration-needed")); if (user.EmailConfirmed) return BadRequest(await _localizationService.Translate(user.Id, "user-already-confirmed")); + // TODO: If the target user is read only, we might want to just forgo this + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); user.ConfirmationToken = token; _unitOfWork.UserRepository.Update(user); diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 3b9f8cdda..4f8edd511 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -1,5 +1,9 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Constants; +using API.Data; using API.Data.ManualMigrations; +using API.DTOs; using API.DTOs.Progress; using API.Entities; using Microsoft.AspNetCore.Authorization; @@ -27,18 +31,7 @@ public class AdminController : BaseApiController [HttpGet("exists")] public async Task> AdminExists() { - var users = await _userManager.GetUsersInRoleAsync("Admin"); + var users = await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); return users.Count > 0; } - - /// - /// Set the progress information for a particular user - /// - /// - [Authorize("RequireAdminRole")] - [HttpPost("update-chapter-progress")] - public async Task> UpdateChapterProgress(UpdateUserProgressDto dto) - { - return Ok(await Task.FromResult(false)); - } } diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 962500ec7..e1d7da9e8 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -50,7 +50,7 @@ public class BookController : BaseApiController case MangaFormat.Epub: { var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0]; - using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions); bookTitle = book.Title; break; } @@ -102,7 +102,7 @@ public class BookController : BaseApiController var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist")); - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions); var key = BookService.CoalesceKeyForAnyFile(book, file); if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing")); diff --git a/API/Controllers/CBLController.cs b/API/Controllers/CBLController.cs index fe274dacb..150628ced 100644 --- a/API/Controllers/CBLController.cs +++ b/API/Controllers/CBLController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using API.Constants; using API.DTOs.ReadingLists.CBL; using API.Extensions; using API.Services; @@ -20,11 +21,13 @@ public class CblController : BaseApiController { private readonly IReadingListService _readingListService; private readonly IDirectoryService _directoryService; + private readonly ILocalizationService _localizationService; - public CblController(IReadingListService readingListService, IDirectoryService directoryService) + public CblController(IReadingListService readingListService, IDirectoryService directoryService, ILocalizationService localizationService) { _readingListService = readingListService; _directoryService = directoryService; + _localizationService = localizationService; } /// @@ -91,6 +94,8 @@ public class CblController : BaseApiController [SwaggerIgnore] public async Task> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool useComicVineMatching = false) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var userId = User.GetUserId(); diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs index 5bd239086..94535d499 100644 --- a/API/Controllers/ChapterController.cs +++ b/API/Controllers/ChapterController.cs @@ -2,18 +2,24 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Helpers; using API.Services; using API.Services.Tasks.Scanner.Parser; using API.SignalR; +using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Nager.ArticleNumber; @@ -25,13 +31,16 @@ public class ChapterController : BaseApiController private readonly ILocalizationService _localizationService; private readonly IEventHub _eventHub; private readonly ILogger _logger; + private readonly IMapper _mapper; - public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger logger) + public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger logger, + IMapper mapper) { _unitOfWork = unitOfWork; _localizationService = localizationService; _eventHub = eventHub; _logger = logger; + _mapper = mapper; } /// @@ -58,7 +67,10 @@ public class ChapterController : BaseApiController [HttpDelete] public async Task> DeleteChapter(int chapterId) { - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId, + ChapterIncludes.Files | ChapterIncludes.ExternalReviews | ChapterIncludes.ExternalRatings); if (chapter == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); @@ -76,6 +88,15 @@ public class ChapterController : BaseApiController _unitOfWork.ChapterRepository.Remove(chapter); } + // If we removed the volume, do an additional check if we need to delete the actual series as well or not + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(vol.SeriesId, SeriesIncludes.ExternalData | SeriesIncludes.Volumes); + var needToRemoveSeries = needToRemoveVolume && series != null && series.Volumes.Count <= 1; + if (needToRemoveSeries) + { + _unitOfWork.SeriesRepository.Remove(series!); + } + + if (!await _unitOfWork.CommitAsync()) return Ok(false); @@ -85,6 +106,12 @@ public class ChapterController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(chapter.VolumeId, vol.SeriesId), false); } + if (needToRemoveSeries) + { + await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, + MessageFactory.SeriesRemovedEvent(series!.Id, series.Name, series.LibraryId), false); + } + return Ok(true); } @@ -182,6 +209,7 @@ public class ChapterController : BaseApiController if (chapter.AgeRating != dto.AgeRating) { chapter.AgeRating = dto.AgeRating; + chapter.KPlusOverrides.Remove(MetadataSettingField.AgeRating); } dto.Summary ??= string.Empty; @@ -189,6 +217,7 @@ public class ChapterController : BaseApiController if (chapter.Summary != dto.Summary.Trim()) { chapter.Summary = dto.Summary.Trim(); + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterSummary); } if (chapter.Language != dto.Language) @@ -204,11 +233,13 @@ public class ChapterController : BaseApiController if (chapter.TitleName != dto.TitleName) { chapter.TitleName = dto.TitleName; + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterTitle); } if (chapter.ReleaseDate != dto.ReleaseDate) { chapter.ReleaseDate = dto.ReleaseDate; + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterReleaseDate); } if (!string.IsNullOrEmpty(dto.ISBN) && ArticleNumberHelper.IsValidIsbn10(dto.ISBN) || @@ -231,131 +262,123 @@ public class ChapterController : BaseApiController #region Genres - if (dto.Genres is {Count: > 0}) - { - chapter.Genres ??= new List(); - await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), _unitOfWork); - } + chapter.Genres ??= []; + await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), _unitOfWork); #endregion #region Tags - if (dto.Tags is {Count: > 0}) - { - chapter.Tags ??= new List(); - await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), _unitOfWork); - } + chapter.Tags ??= []; + await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), _unitOfWork); #endregion #region People - if (PersonHelper.HasAnyPeople(dto)) - { - chapter.People ??= new List(); + chapter.People ??= []; + // Update writers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Writers.Select(p => p.Name).ToList(), + PersonRole.Writer, + _unitOfWork + ); - // Update writers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Writers.Select(p => p.Name).ToList(), - PersonRole.Writer, - _unitOfWork - ); + // Update characters + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Characters.Select(p => p.Name).ToList(), + PersonRole.Character, + _unitOfWork + ); - // Update characters - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Characters.Select(p => p.Name).ToList(), - PersonRole.Character, - _unitOfWork - ); + // Update pencillers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Pencillers.Select(p => p.Name).ToList(), + PersonRole.Penciller, + _unitOfWork + ); - // Update pencillers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Pencillers.Select(p => p.Name).ToList(), - PersonRole.Penciller, - _unitOfWork - ); + // Update inkers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Inkers.Select(p => p.Name).ToList(), + PersonRole.Inker, + _unitOfWork + ); - // Update inkers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Inkers.Select(p => p.Name).ToList(), - PersonRole.Inker, - _unitOfWork - ); + // Update colorists + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Colorists.Select(p => p.Name).ToList(), + PersonRole.Colorist, + _unitOfWork + ); - // Update colorists - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Colorists.Select(p => p.Name).ToList(), - PersonRole.Colorist, - _unitOfWork - ); + // Update letterers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Letterers.Select(p => p.Name).ToList(), + PersonRole.Letterer, + _unitOfWork + ); - // Update letterers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Letterers.Select(p => p.Name).ToList(), - PersonRole.Letterer, - _unitOfWork - ); + // Update cover artists + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.CoverArtists.Select(p => p.Name).ToList(), + PersonRole.CoverArtist, + _unitOfWork + ); - // Update cover artists - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.CoverArtists.Select(p => p.Name).ToList(), - PersonRole.CoverArtist, - _unitOfWork - ); + // Update editors + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Editors.Select(p => p.Name).ToList(), + PersonRole.Editor, + _unitOfWork + ); - // Update editors - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Editors.Select(p => p.Name).ToList(), - PersonRole.Editor, - _unitOfWork - ); + // TODO: Only remove field if changes were made + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterPublisher); + // Update publishers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Publishers.Select(p => p.Name).ToList(), + PersonRole.Publisher, + _unitOfWork + ); - // Update publishers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Publishers.Select(p => p.Name).ToList(), - PersonRole.Publisher, - _unitOfWork - ); + // Update translators + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Translators.Select(p => p.Name).ToList(), + PersonRole.Translator, + _unitOfWork + ); - // Update translators - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Translators.Select(p => p.Name).ToList(), - PersonRole.Translator, - _unitOfWork - ); + // Update imprints + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Imprints.Select(p => p.Name).ToList(), + PersonRole.Imprint, + _unitOfWork + ); - // Update imprints - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Imprints.Select(p => p.Name).ToList(), - PersonRole.Imprint, - _unitOfWork - ); + // Update teams + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Teams.Select(p => p.Name).ToList(), + PersonRole.Team, + _unitOfWork + ); - // Update teams - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Teams.Select(p => p.Name).ToList(), - PersonRole.Team, - _unitOfWork - ); - - // Update locations - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Locations.Select(p => p.Name).ToList(), - PersonRole.Location, - _unitOfWork - ); - } + // Update locations + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Locations.Select(p => p.Name).ToList(), + PersonRole.Location, + _unitOfWork + ); #endregion #region Locks @@ -397,6 +420,39 @@ public class ChapterController : BaseApiController return Ok(); } + /// + /// Returns Ratings and Reviews for an individual Chapter + /// + /// + /// + [HttpGet("chapter-detail-plus")] + public async Task> ChapterDetailPlus([FromQuery] int chapterId) + { + var ret = new ChapterDetailPlusDto(); + var userReviews = (await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, User.GetUserId())) + .Where(r => !string.IsNullOrEmpty(r.Body)) + .OrderByDescending(review => review.Username.Equals(User.GetUsername()) ? 1 : 0) + .ToList(); + + var ownRating = await _unitOfWork.UserRepository.GetUserChapterRatingAsync(User.GetUserId(), chapterId); + if (ownRating != null) + { + ret.Rating = ownRating.Rating; + ret.HasBeenRated = ownRating.HasBeenRated; + } + + var externalReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReviewDtos(chapterId); + if (externalReviews.Count > 0) + { + userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(externalReviews)); + } + + ret.Reviews = userReviews; + + ret.Ratings = await _unitOfWork.ChapterRepository.GetExternalChapterRatingDtos(chapterId); + + return Ok(ret); + } } diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index b49d6aa40..2c0abc609 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -105,6 +105,8 @@ public class CollectionController : BaseApiController [HttpPost("update")] public async Task UpdateTag(AppUserCollectionDto updatedTag) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { if (await _collectionService.UpdateTag(updatedTag, User.GetUserId())) @@ -130,6 +132,8 @@ public class CollectionController : BaseApiController [HttpPost("promote-multiple")] public async Task PromoteMultipleCollections(PromoteCollectionsDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // This needs to take into account owner as I can select other users cards var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds); var userId = User.GetUserId(); @@ -161,6 +165,8 @@ public class CollectionController : BaseApiController [HttpPost("delete-multiple")] public async Task DeleteMultipleCollections(DeleteCollectionsDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // This needs to take into account owner as I can select other users cards var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); if (user == null) return Unauthorized(); @@ -182,6 +188,8 @@ public class CollectionController : BaseApiController [HttpPost("update-for-series")] public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // Create a new tag and save var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); if (user == null) return Unauthorized(); @@ -223,6 +231,8 @@ public class CollectionController : BaseApiController [HttpPost("update-series")] public async Task RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(updateSeriesForTagDto.Tag.Id, CollectionIncludes.Series); @@ -247,6 +257,8 @@ public class CollectionController : BaseApiController [HttpDelete] public async Task DeleteTag(int tagId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); @@ -276,6 +288,8 @@ public class CollectionController : BaseApiController [HttpGet("mal-stacks")] public async Task>> GetMalStacksForUser() { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId())); } @@ -289,6 +303,8 @@ public class CollectionController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // Validation check to ensure stack doesn't exist already if (await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, user.Id)) diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index ba65aec70..5a249c9a8 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Downloads; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Services; using API.SignalR; @@ -157,7 +158,8 @@ public class DownloadController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, filename, $"Downloading {filename}", 0F, "started")); - if (files.Count == 1) + + if (files.Count == 1 && files.First().Format != MangaFormat.Image) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, @@ -166,15 +168,17 @@ public class DownloadController : BaseApiController } var filePath = _archiveService.CreateZipFromFoldersForDownload(files.Select(c => c.FilePath).ToList(), tempFolder, ProgressCallback); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, filename, "Download Complete", 1F, "ended")); + return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true); async Task ProgressCallback(Tuple progressInfo) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(username, filename, $"Extracting {Path.GetFileNameWithoutExtension(progressInfo.Item1)}", + MessageFactory.DownloadProgressEvent(username, filename, $"Processing {Path.GetFileNameWithoutExtension(progressInfo.Item1)}", Math.Clamp(progressInfo.Item2, 0F, 1F))); } } @@ -192,8 +196,10 @@ public class DownloadController : BaseApiController public async Task DownloadSeries(int seriesId) { if (!await HasDownloadPermission()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); if (series == null) return BadRequest("Invalid Series"); + var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); try { diff --git a/API/Controllers/EmailController.cs b/API/Controllers/EmailController.cs new file mode 100644 index 000000000..c1e3ad413 --- /dev/null +++ b/API/Controllers/EmailController.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Email; +using API.Helpers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +[Authorize(Policy = "RequireAdminRole")] +public class EmailController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + + public EmailController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + [HttpGet("all")] + public async Task>> GetEmails() + { + return Ok(await _unitOfWork.EmailHistoryRepository.GetEmailDtos(UserParams.Default)); + } +} diff --git a/API/Controllers/FilterController.cs b/API/Controllers/FilterController.cs index e8cb71117..7fcffb7da 100644 --- a/API/Controllers/FilterController.cs +++ b/API/Controllers/FilterController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs.Dashboard; @@ -9,7 +10,9 @@ using API.DTOs.Filtering.v2; using API.Entities; using API.Extensions; using API.Helpers; +using API.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -21,10 +24,17 @@ namespace API.Controllers; public class FilterController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IStreamService _streamService; + private readonly ILogger _logger; - public FilterController(IUnitOfWork unitOfWork) + public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IStreamService streamService, + ILogger logger) { _unitOfWork = unitOfWork; + _localizationService = localizationService; + _streamService = streamService; + _logger = logger; } /// @@ -37,6 +47,7 @@ public class FilterController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.SmartFilters); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name must be set"); if (Seed.DefaultStreams.Any(s => s.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase))) @@ -78,6 +89,8 @@ public class FilterController : BaseApiController [HttpDelete] public async Task DeleteFilter(int filterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId); if (filter == null) return Ok(); // This needs to delete any dashboard filters that have it too @@ -113,4 +126,57 @@ public class FilterController : BaseApiController { return Ok(SmartFilterHelper.Decode(dto.EncodedFilter)); } + + /// + /// Rename a Smart Filter given the filterId and new name + /// + /// + /// + /// + [HttpPost("rename")] + public async Task RenameFilter([FromQuery] int filterId, [FromQuery] string name) + { + try + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), + AppUserIncludes.SmartFilters); + if (user == null) return Unauthorized(); + + name = name.Trim(); + + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) + { + return BadRequest(await _localizationService.Translate(user.Id, "permission-denied")); + } + + if (string.IsNullOrWhiteSpace(name)) + { + return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-name-required")); + } + + if (Seed.DefaultStreams.Any(s => s.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))) + { + return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-system-name")); + } + + var filter = user.SmartFilters.FirstOrDefault(f => f.Id == filterId); + if (filter == null) + { + return BadRequest(await _localizationService.Translate(user.Id, "filter-not-found")); + } + + filter.Name = name; + _unitOfWork.AppUserSmartFilterRepository.Update(filter); + await _unitOfWork.CommitAsync(); + + await _streamService.RenameSmartFilterStreams(filter); + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when renaming smart filter: {FilterId}", filterId); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + } + + } } diff --git a/API/Controllers/KoreaderController.cs b/API/Controllers/KoreaderController.cs new file mode 100644 index 000000000..8c4c41585 --- /dev/null +++ b/API/Controllers/KoreaderController.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Koreader; +using API.Entities; +using API.Extensions; +using API.Services; +using Kavita.Common; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using static System.Net.WebRequestMethods; + +namespace API.Controllers; +#nullable enable + +/// +/// The endpoint to interface with Koreader's Progress Sync plugin. +/// +/// +/// Koreader uses a different form of authentication. It stores the username and password in headers. +/// https://github.com/koreader/koreader/blob/master/plugins/kosync.koplugin/KOSyncClient.lua +/// +[AllowAnonymous] +public class KoreaderController : BaseApiController +{ + + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IKoreaderService _koreaderService; + private readonly ILogger _logger; + + public KoreaderController(IUnitOfWork unitOfWork, ILocalizationService localizationService, + IKoreaderService koreaderService, ILogger logger) + { + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _koreaderService = koreaderService; + _logger = logger; + } + + // We won't allow users to be created from Koreader. Rather, they + // must already have an account. + /* + [HttpPost("/users/create")] + public IActionResult CreateUser(CreateUserRequest request) + { + } + */ + + [HttpGet("{apiKey}/users/auth")] + public async Task Authenticate(string apiKey) + { + var userId = await GetUserId(apiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null) return Unauthorized(); + + return Ok(new { username = user.UserName }); + } + + /// + /// Syncs book progress with Kavita. Will attempt to save the underlying reader position if possible. + /// + /// + /// + /// + [HttpPut("{apiKey}/syncs/progress")] + public async Task> UpdateProgress(string apiKey, KoreaderBookDto request) + { + try + { + var userId = await GetUserId(apiKey); + await _koreaderService.SaveProgress(request, userId); + + return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow }); + } + catch (KavitaException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Gets book progress from Kavita, if not found will return a 400 + /// + /// + /// + /// + [HttpGet("{apiKey}/syncs/progress/{ebookHash}")] + public async Task> GetProgress(string apiKey, string ebookHash) + { + try + { + var userId = await GetUserId(apiKey); + var response = await _koreaderService.GetProgress(ebookHash, userId); + _logger.LogDebug("Koreader response progress for User ({UserId}): {Progress}", userId, response.Progress.Sanitize()); + + return Ok(response); + } + catch (KavitaException ex) + { + return BadRequest(ex.Message); + } + } + + private async Task GetUserId(string apiKey) + { + try + { + return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + } + catch + { + throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist")); + } + } +} diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 34b81c20b..8f9b18317 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -81,7 +81,8 @@ public class LibraryController : BaseApiController .WithIncludeInDashboard(dto.IncludeInDashboard) .WithManageCollections(dto.ManageCollections) .WithManageReadingLists(dto.ManageReadingLists) - .WIthAllowScrobbling(dto.AllowScrobbling) + .WithAllowScrobbling(dto.AllowScrobbling) + .WithAllowMetadataMatching(dto.AllowMetadataMatching) .Build(); library.LibraryFileTypes = dto.FileGroupTypes @@ -192,7 +193,6 @@ public class LibraryController : BaseApiController var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username).ToList(); await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); - _logger.LogDebug("Caching libraries for {Key}", cacheKey); return Ok(ret.Find(l => l.Id == libraryId)); } @@ -213,7 +213,6 @@ public class LibraryController : BaseApiController var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username); await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); - _logger.LogDebug("Caching libraries for {Key}", cacheKey); return Ok(ret); } @@ -351,27 +350,6 @@ public class LibraryController : BaseApiController return Ok(); } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("analyze")] - public ActionResult Analyze(int libraryId) - { - _taskScheduler.AnalyzeFilesForLibrary(libraryId, true); - return Ok(); - } - - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("analyze-multiple")] - public ActionResult AnalyzeMultiple(BulkActionDto dto) - { - foreach (var libraryId in dto.Ids) - { - _taskScheduler.AnalyzeFilesForLibrary(libraryId, dto.Force ?? false); - } - - return Ok(); - } - - /// /// Copy the library settings (adv tab + optional type) to a set of other libraries. /// @@ -440,8 +418,7 @@ public class LibraryController : BaseApiController .Distinct() .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); - var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, - new List() {dto.FolderPath}); + var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]); _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); @@ -645,6 +622,10 @@ public class LibraryController : BaseApiController library.ManageCollections = dto.ManageCollections; library.ManageReadingLists = dto.ManageReadingLists; library.AllowScrobbling = dto.AllowScrobbling; + library.AllowMetadataMatching = dto.AllowMetadataMatching; + library.EnableMetadata = dto.EnableMetadata; + library.RemovePrefixForSortName = dto.RemovePrefixForSortName; + library.LibraryFileTypes = dto.FileGroupTypes .Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id}) .Distinct() diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs index d831f10b2..30ed68771 100644 --- a/API/Controllers/LicenseController.cs +++ b/API/Controllers/LicenseController.cs @@ -2,14 +2,17 @@ using System.Threading.Tasks; using API.Constants; using API.Data; -using API.DTOs.License; +using API.DTOs.KavitaPlus.License; using API.Entities.Enums; using API.Extensions; using API.Services; using API.Services.Plus; +using EasyCaching.Core; +using Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using TaskScheduler = API.Services.TaskScheduler; namespace API.Controllers; @@ -20,7 +23,8 @@ public class LicenseController( ILogger logger, ILicenseService licenseService, ILocalizationService localizationService, - ITaskScheduler taskScheduler) + ITaskScheduler taskScheduler, + IEasyCachingProviderFactory cachingProviderFactory) : BaseApiController { /// @@ -31,8 +35,13 @@ public class LicenseController( [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] public async Task> HasValidLicense(bool forceCheck = false) { + var result = await licenseService.HasActiveLicense(forceCheck); - if (result) + + var licenseInfoProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + var cacheValue = await licenseInfoProvider.GetAsync(LicenseService.CacheKey); + + if (result && !cacheValue.IsNull && !cacheValue.Value) { await taskScheduler.ScheduleKavitaPlusTasks(); } @@ -41,7 +50,7 @@ public class LicenseController( } /// - /// Has any license + /// Has any license registered with the instance. Does not check Kavita+ API /// /// [Authorize("RequireAdminRole")] @@ -53,6 +62,30 @@ public class LicenseController( (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value)); } + /// + /// Asks Kavita+ for the latest license info + /// + /// Force checking the API and skip the 8 hour cache + /// + [Authorize("RequireAdminRole")] + [HttpGet("info")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] + public async Task> GetLicenseInfo(bool forceCheck = false) + { + try + { + return Ok(await licenseService.GetLicenseInfo(forceCheck)); + } + catch (Exception) + { + return Ok(null); + } + } + + /// + /// Remove the Kavita+ License on the Server + /// + /// [Authorize("RequireAdminRole")] [HttpDelete] [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] @@ -63,10 +96,13 @@ public class LicenseController( setting.Value = null; unitOfWork.SettingsRepository.Update(setting); await unitOfWork.CommitAsync(); - await taskScheduler.ScheduleKavitaPlusTasks(); + + TaskScheduler.RemoveKavitaPlusTasks(); + return Ok(); } + [Authorize("RequireAdminRole")] [HttpPost("reset")] public async Task ResetLicense(UpdateLicenseDto dto) diff --git a/API/Controllers/LocaleController.cs b/API/Controllers/LocaleController.cs index 9190c72cb..6e3a2ec78 100644 --- a/API/Controllers/LocaleController.cs +++ b/API/Controllers/LocaleController.cs @@ -2,9 +2,16 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.DTOs; using API.DTOs.Filtering; using API.Services; +using EasyCaching.Core; +using Kavita.Common.EnvironmentInfo; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; namespace API.Controllers; @@ -13,43 +20,34 @@ namespace API.Controllers; public class LocaleController : BaseApiController { private readonly ILocalizationService _localizationService; + private readonly IEasyCachingProvider _localeCacheProvider; - public LocaleController(ILocalizationService localizationService) + private static readonly string CacheKey = "locales_" + BuildInfo.Version; + + public LocaleController(ILocalizationService localizationService, IEasyCachingProviderFactory cachingProviderFactory) { _localizationService = localizationService; + _localeCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.LocaleOptions); } + /// + /// Returns all applicable locales on the server + /// + /// This can be cached as it will not change per version. + /// + [AllowAnonymous] [HttpGet] - public ActionResult> GetAllLocales() + public async Task>> GetAllLocales() { - // Check if temp/locale_map.json exists + var result = await _localeCacheProvider.GetAsync>(CacheKey); + if (result.HasValue) + { + return Ok(result.Value); + } - // If not, scan the 2 locale files and calculate empty keys or empty values + var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f); + await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(1)); - // Formulate the Locale object with Percentage - var languages = _localizationService.GetLocales().Select(c => - { - try - { - var cult = new CultureInfo(c); - return new LanguageDto() - { - Title = cult.DisplayName, - IsoCode = cult.IetfLanguageTag - }; - } - catch (Exception ex) - { - // Some OS' don't have all culture codes supported like PT_BR, thus we need to default - return new LanguageDto() - { - Title = c, - IsoCode = c - }; - } - }) - .Where(l => !string.IsNullOrEmpty(l.IsoCode)) - .OrderBy(d => d.Title); - return Ok(languages); + return Ok(ret); } } diff --git a/API/Controllers/ManageController.cs b/API/Controllers/ManageController.cs new file mode 100644 index 000000000..3641ddd74 --- /dev/null +++ b/API/Controllers/ManageController.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.DTOs.KavitaPlus.Manage; +using API.Services.Plus; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +/// +/// All things centered around Managing the Kavita instance, that isn't aligned with an entity +/// +[Authorize("RequireAdminRole")] +public class ManageController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILicenseService _licenseService; + + public ManageController(IUnitOfWork unitOfWork, ILicenseService licenseService) + { + _unitOfWork = unitOfWork; + _licenseService = licenseService; + } + + /// + /// Returns a list of all Series that is Kavita+ applicable to metadata match and the status of it + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("series-metadata")] + public async Task>> SeriesMetadata(ManageMatchFilterDto filter) + { + if (!await _licenseService.HasActiveLicense()) return Ok(Array.Empty()); + + return Ok(await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeries(filter)); + } +} diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 51c8c4a01..cab33692a 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -9,10 +9,13 @@ using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; +using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Services; using API.Services.Plus; using Kavita.Common.Extensions; @@ -32,16 +35,35 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// Fetches genres from the instance /// /// String separated libraryIds or null for all genres + /// Context from which this API was invoked /// [HttpGet("genres")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds", "context"])] public async Task>> GetAllGenres(string? libraryIds, QueryContext context = QueryContext.None) { - var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToList(); return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context)); } + /// + /// Returns a list of Genres with counts for counts when Genre is on Series/Chapter + /// + /// + [HttpPost("genres-with-counts")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)] + public async Task>> GetBrowseGenres(UserParams? userParams = null) + { + userParams ??= UserParams.Default; + + var list = await unitOfWork.GenreRepository.GetBrowseableGenre(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + + return Ok(list); + } + /// /// Fetches people from the instance by role /// @@ -70,6 +92,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc { return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids)); } + return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId())); } @@ -90,6 +113,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId())); } + /// + /// Returns a list of Tags with counts for counts when Tag is on Series/Chapter + /// + /// + [HttpPost("tags-with-counts")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)] + public async Task>> GetBrowseTags(UserParams? userParams = null) + { + userParams ??= UserParams.Default; + + var list = await unitOfWork.TagRepository.GetBrowseableTag(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + + return Ok(list); + } + /// /// Fetches all age ratings from the instance /// @@ -189,12 +228,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// /// /// - [HttpPost("force-refresh")] - public async Task ForceRefresh(int seriesId) - { - await metadataService.ForceKavitaPlusRefresh(seriesId); - return Ok(); - } + // [HttpPost("force-refresh")] + // public async Task ForceRefresh(int seriesId) + // { + // await metadataService.ForceKavitaPlusRefresh(seriesId); + // return Ok(); + // } /// /// Fetches the details needed from Kavita+ for Series Detail page @@ -217,12 +256,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc return Ok(ret); } - private async Task PrepareSeriesDetail(List userReviews, SeriesDetailPlusDto ret) + private async Task PrepareSeriesDetail(List userReviews, SeriesDetailPlusDto? ret) { var isAdmin = User.IsInRole(PolicyConstants.AdminRole); var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!; - userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList())); + userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(ret.Reviews.ToList())); ret.Reviews = userReviews; if (!isAdmin && ret.Recommendations != null && user != null) @@ -231,12 +270,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc ret.Recommendations.OwnedSeries = await unitOfWork.SeriesRepository.GetSeriesDtoByIdsAsync( ret.Recommendations.OwnedSeries.Select(s => s.Id), user); - ret.Recommendations.ExternalSeries = new List(); + ret.Recommendations.ExternalSeries = []; } if (ret.Recommendations != null && user != null) { - ret.Recommendations.OwnedSeries ??= new List(); + ret.Recommendations.OwnedSeries ??= []; await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries); } } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index e122ae9f9..6e96c3063 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; +using System.Xml; using System.Xml.Serialization; using API.Comparators; using API.Data; @@ -14,6 +15,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.OPDS; +using API.DTOs.Person; using API.DTOs.Progress; using API.DTOs.Search; using API.Entities; @@ -26,6 +28,7 @@ using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using MimeTypes; namespace API.Controllers; @@ -35,6 +38,7 @@ namespace API.Controllers; [AllowAnonymous] public class OpdsController : BaseApiController { + private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly IDownloadService _downloadService; private readonly IDirectoryService _directoryService; @@ -81,7 +85,7 @@ public class OpdsController : BaseApiController IDirectoryService directoryService, ICacheService cacheService, IReaderService readerService, ISeriesService seriesService, IAccountService accountService, ILocalizationService localizationService, - IMapper mapper) + IMapper mapper, ILogger logger) { _unitOfWork = unitOfWork; _downloadService = downloadService; @@ -92,6 +96,7 @@ public class OpdsController : BaseApiController _accountService = accountService; _localizationService = localizationService; _mapper = mapper; + _logger = logger; _xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); @@ -579,19 +584,25 @@ public class OpdsController : BaseApiController public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0) { var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - var (baseUrl, prefix) = await GetPrefix(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var userWithLists = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user!.UserName!, AppUserIncludes.ReadingListsWithItems); - if (userWithLists == null) return Unauthorized(); - var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + { + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + } + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null) + { + return Unauthorized(); + } + + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, user.Id); if (readingList == null) { return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted")); } + var (baseUrl, prefix) = await GetPrefix(); var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix); SetFeedId(feed, $"reading-list-{readingListId}"); @@ -764,6 +775,12 @@ public class OpdsController : BaseApiController return CreateXmlResult(SerializeXml(feed)); } + /// + /// OPDS Search endpoint + /// + /// + /// + /// [HttpGet("{apiKey}/series")] [Produces("application/xml")] public async Task SearchSeries(string apiKey, [FromQuery] string query) @@ -781,20 +798,21 @@ public class OpdsController : BaseApiController query = query.Replace(@"%", string.Empty); // Get libraries user has access to var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); - if (!libraries.Any()) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted")); + if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - var series = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, libraries.Select(l => l.Id).ToArray(), query); + var searchResults = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, + libraries.Select(l => l.Id).ToArray(), query, includeChapterAndFiles: false); var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey, prefix); SetFeedId(feed, "search-series"); - foreach (var seriesDto in series.Series) + foreach (var seriesDto in searchResults.Series) { feed.Entries.Add(CreateSeries(seriesDto, apiKey, prefix, baseUrl)); } - foreach (var collection in series.Collections) + foreach (var collection in searchResults.Collections) { feed.Entries.Add(new FeedEntry() { @@ -813,7 +831,7 @@ public class OpdsController : BaseApiController }); } - foreach (var readingListDto in series.ReadingLists) + foreach (var readingListDto in searchResults.ReadingLists) { feed.Entries.Add(new FeedEntry() { @@ -827,6 +845,7 @@ public class OpdsController : BaseApiController }); } + // TODO: Search should allow Chapters/Files and more return CreateXmlResult(SerializeXml(feed)); } @@ -1355,9 +1374,48 @@ public class OpdsController : BaseApiController { if (feed == null) return string.Empty; + // Remove invalid XML characters from the feed object + SanitizeFeed(feed); + using var sm = new StringWriter(); _xmlSerializer.Serialize(sm, feed); - return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + var ret = sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + + return ret; + } + + // Recursively sanitize all string properties in the object + private static void SanitizeFeed(object? obj) + { + if (obj == null) return; + + var properties = obj.GetType().GetProperties(); + foreach (var property in properties) + { + // Skip properties that require an index (e.g., indexed collections) + if (property.GetIndexParameters().Length > 0) + continue; + + if (property.PropertyType == typeof(string) && property.CanWrite) + { + var value = (string?)property.GetValue(obj); + if (!string.IsNullOrEmpty(value)) + { + property.SetValue(obj, RemoveInvalidXmlChars(value)); + } + } + else if (property.PropertyType.IsClass) // Handle nested objects + { + var nestedObject = property.GetValue(obj); + if (nestedObject != null) + SanitizeFeed(nestedObject); + } + } + } + + private static string RemoveInvalidXmlChars(string input) + { + return new string(input.Where(XmlConvert.IsXmlChar).ToArray()); } } diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index 22a52c04a..7328ff954 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -1,7 +1,13 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.DTOs; +using API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; +using API.DTOs.Person; using API.Entities.Enums; using API.Extensions; using API.Helpers; @@ -9,6 +15,7 @@ using API.Services; using API.Services.Tasks.Metadata; using API.SignalR; using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nager.ArticleNumber; @@ -23,9 +30,10 @@ public class PersonController : BaseApiController private readonly ICoverDbService _coverDbService; private readonly IImageService _imageService; private readonly IEventHub _eventHub; + private readonly IPersonService _personService; public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper, - ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub) + ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService) { _unitOfWork = unitOfWork; _localizationService = localizationService; @@ -33,6 +41,7 @@ public class PersonController : BaseApiController _coverDbService = coverDbService; _imageService = imageService; _eventHub = eventHub; + _personService = personService; } @@ -42,6 +51,17 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId())); } + /// + /// Find a person by name or alias against a query string + /// + /// + /// + [HttpGet("search")] + public async Task>> SearchPeople([FromQuery] string queryString) + { + return Ok(await _unitOfWork.PersonRepository.SearchPeople(queryString)); + } + /// /// Returns all roles for a Person /// @@ -53,17 +73,20 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId())); } + /// - /// Returns a list of authors & artists for browsing + /// Returns a list of authors and artists for browsing /// /// /// [HttpPost("all")] - public async Task>> GetAuthorsForBrowse([FromQuery] UserParams? userParams) + public async Task>> GetPeopleForBrowse(BrowsePersonFilterDto filter, [FromQuery] UserParams? userParams) { userParams ??= UserParams.Default; - var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams); + + var list = await _unitOfWork.PersonRepository.GetBrowsePersonDtos(User.GetUserId(), filter, userParams); Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + return Ok(list); } @@ -72,11 +95,12 @@ public class PersonController : BaseApiController /// /// /// + [Authorize("RequireAdminRole")] [HttpPost("update")] public async Task> UpdatePerson(UpdatePersonDto dto) { // This needs to get all people and update them equally - var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id); + var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id, PersonIncludes.Aliases); if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required")); @@ -88,7 +112,12 @@ public class PersonController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique")); } + var success = await _personService.UpdatePersonAliasesAsync(person, dto.Aliases); + if (!success) return BadRequest(await _localizationService.Translate(User.GetUserId(), "aliases-have-overlap")); + + person.Name = dto.Name?.Trim(); + person.NormalizedName = person.Name.ToNormalized(); person.Description = dto.Description ?? string.Empty; person.CoverImageLocked = dto.CoverImageLocked; @@ -133,7 +162,11 @@ public class PersonController : BaseApiController var personImage = await _coverDbService.DownloadPersonImageAsync(person, settings.EncodeMediaAs); - if (string.IsNullOrEmpty(personImage)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist")); + if (string.IsNullOrEmpty(personImage)) + { + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist")); + } person.CoverImage = personImage; _imageService.UpdateColorScape(person); @@ -152,7 +185,7 @@ public class PersonController : BaseApiController [HttpGet("series-known-for")] public async Task>> GetKnownSeries(int personId) { - return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId)); + return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId, User.GetUserId())); } /// @@ -167,5 +200,42 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role)); } + /// + /// Merges Persons into one, this action is irreversible + /// + /// + /// + [HttpPost("merge")] + [Authorize("RequireAdminRole")] + public async Task> MergePeople(PersonMergeDto dto) + { + var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All); + if (dst == null) return BadRequest(); + + var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId, PersonIncludes.All); + if (src == null) return BadRequest(); + + await _personService.MergePeopleAsync(src, dst); + await _eventHub.SendMessageAsync(MessageFactory.PersonMerged, MessageFactory.PersonMergedMessage(dst, src)); + + return Ok(_mapper.Map(dst)); + } + + /// + /// Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias. + /// + /// + /// + /// + [HttpGet("valid-alias")] + public async Task> IsValidAlias(int personId, string alias) + { + var person = await _unitOfWork.PersonRepository.GetPersonById(personId, PersonIncludes.Aliases); + if (person == null) return NotFound(); + + var existingAlias = await _unitOfWork.PersonRepository.AnyAliasExist(alias); + return Ok(!existingAlias && person.NormalizedName != alias.ToNormalized()); + } + } diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index 87cfaf2c2..f39462bbf 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -30,7 +30,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService public async Task> Authenticate([Required] string apiKey, [Required] string pluginName) { // NOTE: In order to log information about plugins, we need some Plugin Description information for each request - // Should log into access table so we can tell the user + // Should log into the access table so we can tell the user var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var userAgent = HttpContext.Request.Headers.UserAgent; var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -45,7 +45,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService throw new KavitaUnauthenticatedUserException(); } var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId); - logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); + logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({AppUserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); return new UserDto { diff --git a/API/Controllers/RatingController.cs b/API/Controllers/RatingController.cs index a40b6680b..9283ef6d3 100644 --- a/API/Controllers/RatingController.cs +++ b/API/Controllers/RatingController.cs @@ -1,15 +1,12 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using API.Constants; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.Extensions; +using API.Services; using API.Services.Plus; -using EasyCaching.Core; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -21,21 +18,85 @@ namespace API.Controllers; public class RatingController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly IRatingService _ratingService; + private readonly ILocalizationService _localizationService; - public RatingController(IUnitOfWork unitOfWork) + public RatingController(IUnitOfWork unitOfWork, IRatingService ratingService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; - + _ratingService = ratingService; + _localizationService = localizationService; } - [HttpGet("overall")] - public async Task> GetOverallRating(int seriesId) + /// + /// Update the users' rating of the given series + /// + /// + /// + /// + [HttpPost("series")] + public async Task UpdateSeriesRating(UpdateRatingDto updateRating) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings); + if (user == null) throw new UnauthorizedAccessException(); + + if (await _ratingService.UpdateSeriesRating(user, updateRating)) + { + return Ok(); + } + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + } + + /// + /// Update the users' rating of the given chapter + /// + /// chapterId must be set + /// + /// + [HttpPost("chapter")] + public async Task UpdateChapterRating(UpdateRatingDto updateRating) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings); + if (user == null) throw new UnauthorizedAccessException(); + + if (await _ratingService.UpdateChapterRating(user, updateRating)) + { + return Ok(); + } + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + } + + /// + /// Overall rating from all Kavita users for a given Series + /// + /// + /// + [HttpGet("overall-series")] + public async Task> GetOverallSeriesRating(int seriesId) { return Ok(new RatingDto() { Provider = ScrobbleProvider.Kavita, AverageScore = await _unitOfWork.SeriesRepository.GetAverageUserRating(seriesId, User.GetUserId()), - FavoriteCount = 0 + FavoriteCount = 0, + }); + } + + /// + /// Overall rating from all Kavita users for a given Chapter + /// + /// + /// + [HttpGet("overall-chapter")] + public async Task> GetOverallChapterRating(int chapterId) + { + return Ok(new RatingDto() + { + Provider = ScrobbleProvider.Kavita, + AverageScore = await _unitOfWork.ChapterRepository.GetAverageUserRating(chapterId, User.GetUserId()), + FavoriteCount = 0, }); } } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index d4bc8a1fe..38a5ad482 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -803,7 +803,7 @@ public class ReaderController : BaseApiController /// /// [HttpGet("time-left")] - [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId"])] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId"])] public async Task> GetEstimateToCompletion(int seriesId) { var userId = User.GetUserId(); diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 5e84e9f64..1187992bc 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -4,12 +4,12 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; -using API.DTOs; +using API.DTOs.Person; using API.DTOs.ReadingLists; +using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; -using API.SignalR; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,13 +24,15 @@ public class ReadingListController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IReadingListService _readingListService; private readonly ILocalizationService _localizationService; + private readonly IReaderService _readerService; public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService, - ILocalizationService localizationService) + ILocalizationService localizationService, IReaderService readerService) { _unitOfWork = unitOfWork; _readingListService = readingListService; _localizationService = localizationService; + _readerService = readerService; } /// @@ -39,9 +41,15 @@ public class ReadingListController : BaseApiController /// /// [HttpGet] - public async Task>> GetList(int readingListId) + public async Task> GetList(int readingListId) { - return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId())); + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId()); + if (readingList == null) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-restricted")); + } + + return Ok(readingList); } /// @@ -108,6 +116,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-position")] public async Task UpdateListItemPosition(UpdateReadingListPosition dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); // Make sure UI buffers events var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) @@ -122,13 +131,14 @@ public class ReadingListController : BaseApiController } /// - /// Deletes a list item from the list. Will reorder all item positions afterwards + /// Deletes a list item from the list. Item orders will update as a result. /// /// /// [HttpPost("delete-item")] public async Task DeleteListItem(UpdateReadingListPosition dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -151,6 +161,8 @@ public class ReadingListController : BaseApiController [HttpPost("remove-read")] public async Task DeleteReadFromList([FromQuery] int readingListId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { @@ -173,6 +185,7 @@ public class ReadingListController : BaseApiController [HttpDelete] public async Task DeleteList([FromQuery] int readingListId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { @@ -193,6 +206,7 @@ public class ReadingListController : BaseApiController [HttpPost("create")] public async Task> CreateList(CreateReadingListDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingLists); if (user == null) return Unauthorized(); @@ -216,6 +230,7 @@ public class ReadingListController : BaseApiController [HttpPost("update")] public async Task UpdateList(UpdateReadingListDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); @@ -245,6 +260,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-series")] public async Task UpdateListBySeries(UpdateReadingListBySeriesDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -254,7 +270,7 @@ public class ReadingListController : BaseApiController var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var chapterIdsForSeries = - await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId}); + await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync([dto.SeriesId]); // If there are adds, tell tracking this has been modified if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList)) @@ -287,6 +303,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-multiple")] public async Task UpdateListByMultiple(UpdateReadingListByMultipleDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -331,6 +348,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-multiple-series")] public async Task UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -369,6 +387,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-volume")] public async Task UpdateListByVolume(UpdateReadingListByVolumeDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -405,6 +424,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-chapter")] public async Task UpdateListByChapter(UpdateReadingListByChapterDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -435,26 +455,38 @@ public class ReadingListController : BaseApiController return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } + /// - /// Returns a list of characters associated with the reading list + /// Returns a list of a given role associated with the reading list + /// + /// + /// PersonRole + /// + [HttpGet("people")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId", "role"])] + public ActionResult> GetPeopleByRoleForList(int readingListId, PersonRole role) + { + return Ok(_unitOfWork.ReadingListRepository.GetReadingListPeopleAsync(readingListId, role)); + } + + /// + /// Returns all people in given roles for a reading list /// /// /// - [HttpGet("characters")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)] - public ActionResult> GetCharactersForList(int readingListId) + [HttpGet("all-people")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId"])] + public async Task>> GetAllPeopleForList(int readingListId) { - return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId)); + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListAllPeopleAsync(readingListId)); } - - /// /// Returns the next chapter within the reading list /// /// /// - /// Chapter Id for next item, -1 if nothing exists + /// Chapter ID for next item, -1 if nothing exists [HttpGet("next-chapter")] public async Task> GetNextChapter(int currentChapterId, int readingListId) { @@ -514,6 +546,8 @@ public class ReadingListController : BaseApiController [HttpPost("promote-multiple")] public async Task PromoteMultipleReadingLists(PromoteReadingListsDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // This needs to take into account owner as I can select other users cards var userId = User.GetUserId(); if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole)) @@ -558,4 +592,26 @@ public class ReadingListController : BaseApiController return Ok(); } + + /// + /// Returns random information about a Reading List + /// + /// + /// + [HttpGet("info")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["readingListId"])] + public async Task> GetReadingListInfo(int readingListId) + { + var result = await _unitOfWork.ReadingListRepository.GetReadingListInfoAsync(readingListId); + + if (result == null) return Ok(null); + + var timeEstimate = _readerService.GetTimeEstimate(result.WordCount, result.Pages, result.IsAllEpub); + + result.MinHoursToRead = timeEstimate.MinHours; + result.AvgHoursToRead = timeEstimate.AvgHours; + result.MaxHoursToRead = timeEstimate.MaxHours; + + return Ok(result); + } } diff --git a/API/Controllers/ReadingProfileController.cs b/API/Controllers/ReadingProfileController.cs new file mode 100644 index 000000000..bc1b4fa52 --- /dev/null +++ b/API/Controllers/ReadingProfileController.cs @@ -0,0 +1,198 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Extensions; +using API.Services; +using AutoMapper; +using Kavita.Common; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Controllers; + +[Route("api/reading-profile")] +public class ReadingProfileController(ILogger logger, IUnitOfWork unitOfWork, + IReadingProfileService readingProfileService): BaseApiController +{ + + /// + /// Gets all non-implicit reading profiles for a user + /// + /// + [HttpGet("all")] + public async Task>> GetAllReadingProfiles() + { + return Ok(await unitOfWork.AppUserReadingProfileRepository.GetProfilesDtoForUser(User.GetUserId(), true)); + } + + /// + /// Returns the ReadingProfile that should be applied to the given series, walks up the tree. + /// Series -> Library -> Default + /// + /// + /// + /// + [HttpGet("{seriesId:int}")] + public async Task> GetProfileForSeries(int seriesId, [FromQuery] bool skipImplicit) + { + return Ok(await readingProfileService.GetReadingProfileDtoForSeries(User.GetUserId(), seriesId, skipImplicit)); + } + + /// + /// Returns the (potential) Reading Profile bound to the library + /// + /// + /// + [HttpGet("library")] + public async Task> GetProfileForLibrary(int libraryId) + { + return Ok(await readingProfileService.GetReadingProfileDtoForLibrary(User.GetUserId(), libraryId)); + } + + /// + /// Creates a new reading profile for the current user + /// + /// + /// + [HttpPost("create")] + public async Task> CreateReadingProfile([FromBody] UserReadingProfileDto dto) + { + return Ok(await readingProfileService.CreateReadingProfile(User.GetUserId(), dto)); + } + + /// + /// Promotes the implicit profile to a user profile. Removes the series from other profiles + /// + /// + /// + [HttpPost("promote")] + public async Task> PromoteImplicitReadingProfile([FromQuery] int profileId) + { + return Ok(await readingProfileService.PromoteImplicitProfile(User.GetUserId(), profileId)); + } + + /// + /// Update the implicit reading profile for a series, creates one if none exists + /// + /// Any modification to the reader settings during reading will create an implicit profile. Use "update-parent" to save to the bound series profile. + /// + /// + /// + [HttpPost("series")] + public async Task> UpdateReadingProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId) + { + var updatedProfile = await readingProfileService.UpdateImplicitReadingProfile(User.GetUserId(), seriesId, dto); + return Ok(updatedProfile); + } + + /// + /// Updates the non-implicit reading profile for the given series, and removes implicit profiles + /// + /// + /// + /// + [HttpPost("update-parent")] + public async Task> UpdateParentProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId) + { + var newParentProfile = await readingProfileService.UpdateParent(User.GetUserId(), seriesId, dto); + return Ok(newParentProfile); + } + + /// + /// Updates the given reading profile, must belong to the current user + /// + /// + /// The updated reading profile + /// + /// This does not update connected series and libraries. + /// + [HttpPost] + public async Task> UpdateReadingProfile(UserReadingProfileDto dto) + { + return Ok(await readingProfileService.UpdateReadingProfile(User.GetUserId(), dto)); + } + + /// + /// Deletes the given profile, requires the profile to belong to the logged-in user + /// + /// + /// + /// + /// + [HttpDelete] + public async Task DeleteReadingProfile([FromQuery] int profileId) + { + await readingProfileService.DeleteReadingProfile(User.GetUserId(), profileId); + return Ok(); + } + + /// + /// Sets the reading profile for a given series, removes the old one + /// + /// + /// + /// + [HttpPost("series/{seriesId:int}")] + public async Task AddProfileToSeries(int seriesId, [FromQuery] int profileId) + { + await readingProfileService.AddProfileToSeries(User.GetUserId(), profileId, seriesId); + return Ok(); + } + + /// + /// Clears the reading profile for the given series for the currently logged-in user + /// + /// + /// + [HttpDelete("series/{seriesId:int}")] + public async Task ClearSeriesProfile(int seriesId) + { + await readingProfileService.ClearSeriesProfile(User.GetUserId(), seriesId); + return Ok(); + } + + /// + /// Sets the reading profile for a given library, removes the old one + /// + /// + /// + /// + [HttpPost("library/{libraryId:int}")] + public async Task AddProfileToLibrary(int libraryId, [FromQuery] int profileId) + { + await readingProfileService.AddProfileToLibrary(User.GetUserId(), profileId, libraryId); + return Ok(); + } + + /// + /// Clears the reading profile for the given library for the currently logged-in user + /// + /// + /// + /// + [HttpDelete("library/{libraryId:int}")] + public async Task ClearLibraryProfile(int libraryId) + { + await readingProfileService.ClearLibraryProfile(User.GetUserId(), libraryId); + return Ok(); + } + + /// + /// Assigns the reading profile to all passes series, and deletes their implicit profiles + /// + /// + /// + /// + [HttpPost("bulk")] + public async Task BulkAddReadingProfile([FromQuery] int profileId, [FromBody] IList seriesIds) + { + await readingProfileService.BulkAddProfileToSeries(User.GetUserId(), profileId, seriesIds); + return Ok(); + } + +} diff --git a/API/Controllers/ReviewController.cs b/API/Controllers/ReviewController.cs index ae8ce02ee..d4de3db16 100644 --- a/API/Controllers/ReviewController.cs +++ b/API/Controllers/ReviewController.cs @@ -1,8 +1,11 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs.SeriesDetail; +using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Helpers.Builders; using API.Services.Plus; @@ -30,17 +33,17 @@ public class ReviewController : BaseApiController /// - /// Updates the review for a given series + /// Updates the user's review for a given series /// /// /// - [HttpPost] - public async Task> UpdateReview(UpdateUserReviewDto dto) + [HttpPost("series")] + public async Task> UpdateSeriesReview(UpdateUserReviewDto dto) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings); if (user == null) return Unauthorized(); - var ratingBuilder = new RatingBuilder(user.Ratings.FirstOrDefault(r => r.SeriesId == dto.SeriesId)); + var ratingBuilder = new RatingBuilder(await _unitOfWork.UserRepository.GetUserRatingAsync(dto.SeriesId, user.Id)); var rating = ratingBuilder .WithBody(dto.Body) @@ -52,22 +55,58 @@ public class ReviewController : BaseApiController { user.Ratings.Add(rating); } + _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); - BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReviewUpdate(user.Id, dto.SeriesId, string.Empty, dto.Body)); return Ok(_mapper.Map(rating)); } + /// + /// Update the user's review for a given chapter + /// + /// chapterId must be set + /// + [HttpPost("chapter")] + public async Task> UpdateChapterReview(UpdateUserReviewDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings); + if (user == null) return Unauthorized(); + + if (dto.ChapterId == null) return BadRequest(); + + int chapterId = dto.ChapterId.Value; + + var ratingBuilder = new ChapterRatingBuilder(await _unitOfWork.UserRepository.GetUserChapterRatingAsync(user.Id, chapterId)); + + var rating = ratingBuilder + .WithBody(dto.Body) + .WithSeriesId(dto.SeriesId) + .WithChapterId(chapterId) + .Build(); + + if (rating.Id == 0) + { + user.ChapterRatings.Add(rating); + } + + _unitOfWork.UserRepository.Update(user); + + await _unitOfWork.CommitAsync(); + + return Ok(_mapper.Map(rating)); + } + + /// /// Deletes the user's review for the given series /// /// - [HttpDelete] - public async Task DeleteReview(int seriesId) + [HttpDelete("series")] + public async Task DeleteSeriesReview([FromQuery] int seriesId) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings); if (user == null) return Unauthorized(); @@ -80,4 +119,23 @@ public class ReviewController : BaseApiController return Ok(); } + + /// + /// Deletes the user's review for the given chapter + /// + /// + [HttpDelete("chapter")] + public async Task DeleteChapterReview([FromQuery] int chapterId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings); + if (user == null) return Unauthorized(); + + user.ChapterRatings = user.ChapterRatings.Where(r => r.ChapterId != chapterId).ToList(); + + _unitOfWork.UserRepository.Update(user); + + await _unitOfWork.CommitAsync(); + + return Ok(); + } } diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs index 685f3e2a1..986f4f8e7 100644 --- a/API/Controllers/ScrobblingController.cs +++ b/API/Controllers/ScrobblingController.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs.Account; +using API.DTOs.KavitaPlus.Account; using API.DTOs.Scrobbling; using API.Entities.Scrobble; using API.Extensions; @@ -53,7 +54,7 @@ public class ScrobblingController : BaseApiController } /// - /// Get the current user's MAL token & username + /// Get the current user's MAL token and username /// /// [HttpGet("mal-token")] @@ -73,9 +74,9 @@ public class ScrobblingController : BaseApiController /// Update the current user's AniList token /// /// - /// + /// True if the token was new or not [HttpPost("update-anilist-token")] - public async Task UpdateAniListToken(AniListUpdateDto dto) + public async Task> UpdateAniListToken(AniListUpdateDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); @@ -85,31 +86,39 @@ public class ScrobblingController : BaseApiController _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); - if (isNewToken) - { - BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(user.Id)); - } - - return Ok(); + return Ok(isNewToken); } /// /// Update the current user's MAL token (Client ID) and Username /// /// - /// + /// True if the token was new or not [HttpPost("update-mal-token")] - public async Task UpdateMalToken(MalUserInfoDto dto) + public async Task> UpdateMalToken(MalUserInfoDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); + var isNewToken = string.IsNullOrEmpty(user.MalAccessToken); user.MalAccessToken = dto.AccessToken; user.MalUserName = dto.Username; _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); + return Ok(isNewToken); + } + + /// + /// When a user request to generate scrobble events from history. Should only be ran once per user. + /// + /// + [HttpPost("generate-scrobble-events")] + public ActionResult GenerateScrobbleEvents() + { + BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(User.GetUserId())); + return Ok(); } @@ -245,7 +254,7 @@ public class ScrobblingController : BaseApiController } /// - /// Adds a hold against the Series for user's scrobbling + /// Remove a hold against the Series for user's scrobbling /// /// /// @@ -261,4 +270,29 @@ public class ScrobblingController : BaseApiController await _unitOfWork.CommitAsync(); return Ok(); } + + /// + /// Has the logged in user ran scrobble generation + /// + /// + [HttpGet("has-ran-scrobble-gen")] + public async Task> HasRanScrobbleGen() + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId()); + return Ok(user is {HasRunScrobbleEventGeneration: true}); + } + + /// + /// Delete the given scrobble events if they belong to that user + /// + /// + /// + [HttpPost("bulk-remove-events")] + public async Task BulkRemoveScrobbleEvents(IList eventIds) + { + var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), eventIds); + _unitOfWork.ScrobbleRepository.Remove(events); + await _unitOfWork.CommitAsync(); + return Ok(); + } } diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 5aa54d1db..cc89a124e 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -63,6 +63,7 @@ public class SearchController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); + var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 2cf97d9b6..389ff33a7 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -9,20 +9,24 @@ using API.DTOs.Dashboard; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.Metadata; +using API.DTOs.Metadata.Matching; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.MetadataMatching; using API.Extensions; using API.Helpers; using API.Services; using API.Services.Plus; using EasyCaching.Core; +using Hangfire; using Kavita.Common; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -38,14 +42,17 @@ public class SeriesController : BaseApiController private readonly ILicenseService _licenseService; private readonly ILocalizationService _localizationService; private readonly IExternalMetadataService _externalMetadataService; + private readonly IHostEnvironment _environment; private readonly IEasyCachingProvider _externalSeriesCacheProvider; + private readonly IEasyCachingProvider _matchSeriesCacheProvider; private const string CacheKey = "externalSeriesData_"; + private const string MatchSeriesCacheKey = "matchSeries_"; public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService, ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, - IExternalMetadataService externalMetadataService) + IExternalMetadataService externalMetadataService, IHostEnvironment environment) { _logger = logger; _taskScheduler = taskScheduler; @@ -54,8 +61,10 @@ public class SeriesController : BaseApiController _licenseService = licenseService; _localizationService = localizationService; _externalMetadataService = externalMetadataService; + _environment = environment; _externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries); + _matchSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusMatchSeries); } /// @@ -91,7 +100,7 @@ public class SeriesController : BaseApiController /// /// [HttpPost("v2")] - public async Task>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto) + public async Task>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto) { var userId = User.GetUserId(); var series = @@ -183,21 +192,6 @@ public class SeriesController : BaseApiController return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId)); } - - /// - /// Update the user rating for the given series - /// - /// - /// - [HttpPost("update-rating")] - public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings); - if (!await _seriesService.UpdateRating(user!, updateSeriesRatingDto)) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); - return Ok(); - } - /// /// Updates the Series /// @@ -230,7 +224,9 @@ public class SeriesController : BaseApiController // Trigger a refresh when we are moving from a locked image to a non-locked needsRefreshMetadata = true; series.CoverImage = null; - series.CoverImageLocked = updateSeries.CoverImageLocked; + series.CoverImageLocked = false; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Covers); + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); series.ResetColorScape(); } @@ -316,7 +312,7 @@ public class SeriesController : BaseApiController /// /// /// - /// + /// This is not in use /// [HttpPost("all-v2")] public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, @@ -327,8 +323,6 @@ public class SeriesController : BaseApiController await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context); // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); @@ -500,7 +494,7 @@ public class SeriesController : BaseApiController /// /// /// This is cached for an hour - [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})] + [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = ["ageRating"])] [HttpGet("age-rating")] public async Task> GetAgeRating(int ageRating) { @@ -616,4 +610,52 @@ public class SeriesController : BaseApiController return Ok(await _seriesService.GetEstimatedChapterCreationDate(seriesId, userId)); } + /// + /// Sends a request to Kavita+ API for all potential matches, sorted by relevance + /// + /// + /// + [HttpPost("match")] + public async Task>> MatchSeries(MatchSeriesDto dto) + { + var cacheKey = $"{MatchSeriesCacheKey}-{dto.SeriesId}-{dto.Query}"; + var results = await _matchSeriesCacheProvider.GetAsync>(cacheKey); + if (results.HasValue && !_environment.IsDevelopment()) + { + return Ok(results.Value); + } + + var ret = await _externalMetadataService.MatchSeries(dto); + await _matchSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(1)); + + return Ok(ret); + } + + /// + /// This will perform the fix match + /// + /// + /// + /// + [HttpPost("update-match")] + public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int? aniListId, [FromQuery] long? malId, [FromQuery] int? cbrId) + { + BackgroundJob.Enqueue(() => _externalMetadataService.FixSeriesMatch(seriesId, aniListId, malId, cbrId)); + + return Ok(); + } + + /// + /// When true, will not perform a match and will prevent Kavita from attempting to match/scrobble against this series + /// + /// + /// + /// + [HttpPost("dont-match")] + public async Task UpdateDontMatch([FromQuery] int seriesId, [FromQuery] bool dontMatch) + { + await _externalMetadataService.UpdateSeriesDontMatch(seriesId, dontMatch); + return Ok(); + } + } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 8d95d4c23..79f6391e8 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -203,25 +203,27 @@ public class ServerController : BaseApiController /// /// Returns how many versions out of date this install is /// + /// Only count Stable releases [HttpGet("check-out-of-date")] - public async Task> CheckHowOutOfDate() + public async Task> CheckHowOutOfDate(bool stableOnly = true) { - return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind()); + return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind(stableOnly)); } /// /// Pull the Changelog for Kavita from Github and display /// + /// How many releases from the latest to return /// [AllowAnonymous] [HttpGet("changelog")] - public async Task>> GetChangelog() + public async Task>> GetChangelog(int count = 0) { // Strange bug where [Authorize] doesn't work if (User.GetUserId() == 0) return Unauthorized(); - return Ok(await _versionUpdaterService.GetAllReleases()); + return Ok(await _versionUpdaterService.GetAllReleases(count)); } /// diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index e88432c1e..0610c8705 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -5,6 +5,7 @@ using System.Net; using System.Threading.Tasks; using API.Data; using API.DTOs.Email; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; @@ -33,27 +34,26 @@ public class SettingsController : BaseApiController { private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; - private readonly ITaskScheduler _taskScheduler; - private readonly IDirectoryService _directoryService; private readonly IMapper _mapper; private readonly IEmailService _emailService; - private readonly ILibraryWatcher _libraryWatcher; private readonly ILocalizationService _localizationService; + private readonly ISettingsService _settingsService; - public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, - IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher, - ILocalizationService localizationService) + public SettingsController(ILogger logger, IUnitOfWork unitOfWork, IMapper mapper, + IEmailService emailService, ILocalizationService localizationService, ISettingsService settingsService) { _logger = logger; _unitOfWork = unitOfWork; - _taskScheduler = taskScheduler; - _directoryService = directoryService; _mapper = mapper; _emailService = emailService; - _libraryWatcher = libraryWatcher; _localizationService = localizationService; + _settingsService = settingsService; } + /// + /// Returns the base url for this instance (if set) + /// + /// [HttpGet("base-url")] public async Task> GetBaseUrl() { @@ -138,347 +138,33 @@ public class SettingsController : BaseApiController } - + /// + /// Update Server settings + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost] public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) { _logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername()); - // We do not allow CacheDirectory changes, so we will ignore. - var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); - var updateBookmarks = false; - var originalBookmarkDirectory = _directoryService.BookmarkDirectory; - - var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; - if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && - !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) - { - bookmarkDirectory = - _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); - } - - if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) - { - bookmarkDirectory = _directoryService.BookmarkDirectory; - } - - var updateTask = false; - foreach (var setting in currentSettings) - { - if (setting.Key == ServerSettingKey.OnDeckProgressDays && - updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.OnDeckUpdateDays && - updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) - { - if (OsInfo.IsDocker) continue; - setting.Value = updateSettingsDto.Port + string.Empty; - // Port is managed in appSetting.json - Configuration.Port = updateSettingsDto.Port; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.CacheSize && - updateSettingsDto.CacheSize + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.CacheSize + string.Empty; - // CacheSize is managed in appSetting.json - Configuration.CacheSize = updateSettingsDto.CacheSize; - _unitOfWork.SettingsRepository.Update(setting); - } - - updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); - - UpdateEmailSettings(setting, updateSettingsDto); - - - - if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) - { - if (OsInfo.IsDocker) continue; - // Validate IP addresses - foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - if (!IPAddress.TryParse(ipAddress.Trim(), out _)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid", - ipAddress)); - } - } - - setting.Value = updateSettingsDto.IpAddresses; - // IpAddresses is managed in appSetting.json - Configuration.IpAddresses = updateSettingsDto.IpAddresses; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) - { - var path = !updateSettingsDto.BaseUrl.StartsWith('/') - ? $"/{updateSettingsDto.BaseUrl}" - : updateSettingsDto.BaseUrl; - path = !path.EndsWith('/') - ? $"{path}/" - : path; - setting.Value = path; - Configuration.BaseUrl = updateSettingsDto.BaseUrl; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.LoggingLevel && - updateSettingsDto.LoggingLevel + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.LoggingLevel + string.Empty; - LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EnableOpds && - updateSettingsDto.EnableOpds + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableOpds + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EncodeMediaAs && - ((int)updateSettingsDto.EncodeMediaAs).ToString() != setting.Value) - { - setting.Value = ((int)updateSettingsDto.EncodeMediaAs).ToString(); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.CoverImageSize && - ((int)updateSettingsDto.CoverImageSize).ToString() != setting.Value) - { - setting.Value = ((int)updateSettingsDto.CoverImageSize).ToString(); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value) - { - setting.Value = (updateSettingsDto.HostName + string.Empty).Trim(); - setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) - { - // Validate new directory can be used - if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) - { - return BadRequest( - await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions")); - } - - originalBookmarkDirectory = setting.Value; - // Normalize the path deliminators. Just to look nice in DB, no functionality - setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); - _unitOfWork.SettingsRepository.Update(setting); - updateBookmarks = true; - - } - - if (setting.Key == ServerSettingKey.AllowStatCollection && - updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TotalBackups && - updateSettingsDto.TotalBackups + string.Empty != setting.Value) - { - if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-backups")); - } - - setting.Value = updateSettingsDto.TotalBackups + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TotalLogs && - updateSettingsDto.TotalLogs + string.Empty != setting.Value) - { - if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-logs")); - } - - setting.Value = updateSettingsDto.TotalLogs + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EnableFolderWatching && - updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - } - - if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto); - try { - await _unitOfWork.CommitAsync(); - - if (!updateSettingsDto.AllowStatCollection) - { - _taskScheduler.CancelStatsTasks(); - } - else - { - await _taskScheduler.ScheduleStatsTasks(); - } - - if (updateBookmarks) - { - UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory); - } - - if (updateTask) - { - BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); - } - - if (updateSettingsDto.EnableFolderWatching) - { - BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); - } - else - { - BackgroundJob.Enqueue(() => _libraryWatcher.StopWatching()); - } + var d = await _settingsService.UpdateSettings(updateSettingsDto); + return Ok(d); + } + catch (KavitaException ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } catch (Exception ex) { _logger.LogError(ex, "There was an exception when updating server settings"); - await _unitOfWork.RollbackAsync(); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } - - - _logger.LogInformation("Server Settings updated"); - BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); - - return Ok(updateSettingsDto); } - - private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) - { - _directoryService.ExistOrCreate(bookmarkDirectory); - _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); - _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); - } - - private bool UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) - { - if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) - { - //if (updateSettingsDto.TotalBackup) - setting.Value = updateSettingsDto.TaskBackup; - _unitOfWork.SettingsRepository.Update(setting); - - return true; - } - - if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) - { - setting.Value = updateSettingsDto.TaskScan; - _unitOfWork.SettingsRepository.Update(setting); - return true; - } - - if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) - { - setting.Value = updateSettingsDto.TaskCleanup; - _unitOfWork.SettingsRepository.Update(setting); - return true; - } - return false; - } - - private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) - { - if (setting.Key == ServerSettingKey.EmailHost && - updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailPort && - updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailAuthPassword && - updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailAuthUserName && - updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSenderAddress && - updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSenderDisplayName && - updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSizeLimit && - updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailEnableSsl && - updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && - updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - } - - /// /// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup. /// @@ -535,4 +221,36 @@ public class SettingsController : BaseApiController if (string.IsNullOrEmpty(user?.Email)) return BadRequest("Your account has no email on record. Cannot email."); return Ok(await _emailService.SendTestEmail(user!.Email)); } + + /// + /// Get the metadata settings for Kavita+ users. + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("metadata-settings")] + public async Task> GetMetadataSettings() + { + return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto()); + + } + + /// + /// Update the metadata settings for Kavita+ Metadata feature + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("metadata-settings")] + public async Task> UpdateMetadataSettings(MetadataSettingsDto dto) + { + try + { + return Ok(await _settingsService.UpdateMetadataSettings(dto)); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue when updating metadata settings"); + return BadRequest(ex.Message); + } + } } diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index 87080312a..383905edd 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -222,18 +222,4 @@ public class StatsController : BaseApiController return Ok(_statService.GetWordsReadCountByYear(userId)); } - /// - /// Returns for Kavita+ the number of Series that have been processed, errored, and not processed - /// - /// - [Authorize("RequireAdminRole")] - [HttpGet("kavitaplus-metadata-breakdown")] - [ResponseCache(CacheProfileName = "Statistics")] - public async Task>>> GetKavitaPlusMetadataBreakdown() - { - if (!await _licenseService.HasActiveLicense()) - return BadRequest("This data is not available for non-Kavita+ servers"); - return Ok(await _statService.GetKavitaPlusMetadataBreakdown()); - } - } diff --git a/API/Controllers/StreamController.cs b/API/Controllers/StreamController.cs index a694d5b34..049885e78 100644 --- a/API/Controllers/StreamController.cs +++ b/API/Controllers/StreamController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Dashboard; using API.DTOs.SideNav; @@ -19,11 +20,13 @@ public class StreamController : BaseApiController { private readonly IStreamService _streamService; private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; - public StreamController(IStreamService streamService, IUnitOfWork unitOfWork) + public StreamController(IStreamService streamService, IUnitOfWork unitOfWork, ILocalizationService localizationService) { _streamService = streamService; _unitOfWork = unitOfWork; + _localizationService = localizationService; } /// @@ -74,6 +77,7 @@ public class StreamController : BaseApiController [HttpPost("update-external-source")] public async Task> UpdateExternalSource(ExternalSourceDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); // Check if a host and api key exists for the current user return Ok(await _streamService.UpdateExternalSource(User.GetUserId(), dto)); } @@ -86,7 +90,8 @@ public class StreamController : BaseApiController [HttpGet("external-source-exists")] public async Task> ExternalSourceExists(string host, string name, string apiKey) { - return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), host, name, apiKey)); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), name, host, apiKey)); } /// @@ -97,6 +102,7 @@ public class StreamController : BaseApiController [HttpDelete("delete-external-source")] public async Task ExternalSourceExists(int externalSourceId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.DeleteExternalSource(User.GetUserId(), externalSourceId); return Ok(); } @@ -110,6 +116,7 @@ public class StreamController : BaseApiController [HttpPost("add-dashboard-stream")] public async Task> AddDashboard([FromQuery] int smartFilterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateDashboardStreamFromSmartFilter(User.GetUserId(), smartFilterId)); } @@ -121,6 +128,7 @@ public class StreamController : BaseApiController [HttpPost("update-dashboard-stream")] public async Task UpdateDashboardStream(DashboardStreamDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateDashboardStream(User.GetUserId(), dto); return Ok(); } @@ -133,6 +141,7 @@ public class StreamController : BaseApiController [HttpPost("update-dashboard-position")] public async Task UpdateDashboardStreamPosition(UpdateStreamPositionDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateDashboardStreamPosition(User.GetUserId(), dto); return Ok(); } @@ -146,6 +155,7 @@ public class StreamController : BaseApiController [HttpPost("add-sidenav-stream")] public async Task> AddSideNav([FromQuery] int smartFilterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateSideNavStreamFromSmartFilter(User.GetUserId(), smartFilterId)); } @@ -157,6 +167,7 @@ public class StreamController : BaseApiController [HttpPost("add-sidenav-stream-from-external-source")] public async Task> AddSideNavFromExternalSource([FromQuery] int externalSourceId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateSideNavStreamFromExternalSource(User.GetUserId(), externalSourceId)); } @@ -168,6 +179,7 @@ public class StreamController : BaseApiController [HttpPost("update-sidenav-stream")] public async Task UpdateSideNavStream(SideNavStreamDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStream(User.GetUserId(), dto); return Ok(); } @@ -180,6 +192,7 @@ public class StreamController : BaseApiController [HttpPost("update-sidenav-position")] public async Task UpdateSideNavStreamPosition(UpdateStreamPositionDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto); return Ok(); } @@ -187,7 +200,34 @@ public class StreamController : BaseApiController [HttpPost("bulk-sidenav-stream-visibility")] public async Task BulkUpdateSideNavStream(BulkUpdateSideNavStreamVisibilityDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto); return Ok(); } + + /// + /// Removes a Smart Filter from a user's SideNav Streams + /// + /// + /// + [HttpDelete("smart-filter-side-nav-stream")] + public async Task DeleteSmartFilterSideNavStream([FromQuery] int sideNavStreamId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + await _streamService.DeleteSideNavSmartFilterStream(User.GetUserId(), sideNavStreamId); + return Ok(); + } + + /// + /// Removes a Smart Filter from a user's Dashboard Streams + /// + /// + /// + [HttpDelete("smart-filter-dashboard-stream")] + public async Task DeleteSmartFilterDashboardStream([FromQuery] int dashboardStreamId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + await _streamService.DeleteDashboardSmartFilterStream(User.GetUserId(), dashboardStreamId); + return Ok(); + } } diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs index fb9371919..9e4cee20c 100644 --- a/API/Controllers/ThemeController.cs +++ b/API/Controllers/ThemeController.cs @@ -103,7 +103,7 @@ public class ThemeController : BaseApiController [HttpDelete] public async Task>> DeleteTheme(int themeId) { - + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _themeService.DeleteTheme(themeId); return Ok(); @@ -128,6 +128,8 @@ public class ThemeController : BaseApiController [HttpPost("upload-theme")] public async Task> DownloadTheme(IFormFile formFile) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (!formFile.FileName.EndsWith(".css")) return BadRequest("Invalid file"); if (formFile.FileName.Contains("..")) return BadRequest("Invalid file"); var tempFile = await UploadToTemp(formFile); diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 6fee371bb..9652ba494 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -6,8 +6,10 @@ using API.Data; using API.Data.Repositories; using API.DTOs.Uploads; using API.Entities.Enums; +using API.Entities.MetadataMatching; using API.Extensions; using API.Services; +using API.Services.Tasks.Metadata; using API.SignalR; using Flurl.Http; using Microsoft.AspNetCore.Authorization; @@ -31,11 +33,12 @@ public class UploadController : BaseApiController private readonly IEventHub _eventHub; private readonly IReadingListService _readingListService; private readonly ILocalizationService _localizationService; + private readonly ICoverDbService _coverDbService; /// public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService, - ILocalizationService localizationService) + ILocalizationService localizationService, ICoverDbService coverDbService) { _unitOfWork = unitOfWork; _imageService = imageService; @@ -45,6 +48,7 @@ public class UploadController : BaseApiController _eventHub = eventHub; _readingListService = readingListService; _localizationService = localizationService; + _coverDbService = coverDbService; } /// @@ -107,13 +111,12 @@ public class UploadController : BaseApiController lockState = uploadFileDto.LockCover; } - if (!string.IsNullOrEmpty(filePath)) - { - series.CoverImage = filePath; - series.CoverImageLocked = lockState; - _imageService.UpdateColorScape(series); - _unitOfWork.SeriesRepository.Update(series); - } + series.CoverImage = filePath; + series.CoverImageLocked = lockState; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Covers); + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + _unitOfWork.SeriesRepository.Update(series.Metadata); if (_unitOfWork.HasChanges()) { @@ -277,6 +280,7 @@ public class UploadController : BaseApiController chapter.CoverImage = filePath; chapter.CoverImageLocked = lockState; + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterCovers); _unitOfWork.ChapterRepository.Update(chapter); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); if (volume != null) @@ -495,34 +499,8 @@ public class UploadController : BaseApiController var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id); if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); - if (!string.IsNullOrEmpty(uploadFileDto.Url)) - { - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetPersonFormat(uploadFileDto.Id)}"); - - if (!string.IsNullOrEmpty(filePath)) - { - person.CoverImage = filePath; - person.CoverImageLocked = true; - _imageService.UpdateColorScape(person); - _unitOfWork.PersonRepository.Update(person); - } - } - else - { - person.CoverImage = string.Empty; - person.CoverImageLocked = false; - _imageService.UpdateColorScape(person); - _unitOfWork.PersonRepository.Update(person); - } - - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false); - return Ok(); - } - + await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, true); + return Ok(); } catch (Exception e) { diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 26039d700..17ebc758e 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -1,11 +1,14 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.KavitaPlus.Account; using API.Extensions; using API.Services; +using API.Services.Plus; using API.SignalR; using AutoMapper; using Microsoft.AspNetCore.Authorization; @@ -22,14 +25,16 @@ public class UsersController : BaseApiController private readonly IMapper _mapper; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly ILicenseService _licenseService; public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub, - ILocalizationService localizationService) + ILocalizationService localizationService, ILicenseService licenseService) { _unitOfWork = unitOfWork; _mapper = mapper; _eventHub = eventHub; _localizationService = localizationService; + _licenseService = licenseService; } [Authorize(Policy = "RequireAdminRole")] @@ -82,45 +87,36 @@ public class UsersController : BaseApiController return Ok(libs.Any(x => x.Id == libraryId)); } + /// + /// Update the user preferences + /// + /// If the user has ReadOnly role, they will not be able to perform this action + /// + /// [HttpPost("update-preferences")] public async Task> UpdatePreferences(UserPreferencesDto preferencesDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.UserPreferences); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var existingPreferences = user!.UserPreferences; - existingPreferences.ReadingDirection = preferencesDto.ReadingDirection; - existingPreferences.ScalingOption = preferencesDto.ScalingOption; - existingPreferences.PageSplitOption = preferencesDto.PageSplitOption; - existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu; - existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints; - existingPreferences.EmulateBook = preferencesDto.EmulateBook; - existingPreferences.ReaderMode = preferencesDto.ReaderMode; - existingPreferences.LayoutMode = preferencesDto.LayoutMode; - existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor; - existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin; - existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing; - existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily; - existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; - existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; - existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; - existingPreferences.BookReaderWritingStyle = preferencesDto.BookReaderWritingStyle; - existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName; - existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode; - existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode; existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode; existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries; - existingPreferences.LayoutMode = preferencesDto.LayoutMode; existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; existingPreferences.NoTransitions = preferencesDto.NoTransitions; - existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; - existingPreferences.PdfTheme = preferencesDto.PdfTheme; - existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode; - existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode; + if (await _licenseService.HasActiveLicense()) + { + existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled; + existingPreferences.WantToReadSync = preferencesDto.WantToReadSync; + } + + if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id) { @@ -129,11 +125,12 @@ public class UsersController : BaseApiController } - if (_localizationService.GetLocales().Contains(preferencesDto.Locale)) + if (_localizationService.GetLocales().Select(l => l.FileName).Contains(preferencesDto.Locale)) { existingPreferences.Locale = preferencesDto.Locale; } + _unitOfWork.UserRepository.Update(existingPreferences); if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-pref")); @@ -164,4 +161,18 @@ public class UsersController : BaseApiController { return Ok((await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(u => u.UserName)); } + + /// + /// Returns all users with tokens registered and their token information. Does not send the tokens. + /// + /// Kavita+ only + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("tokens")] + public async Task>> GetUserTokens() + { + if (!await _licenseService.HasActiveLicense()) return BadRequest(_localizationService.Translate(User.GetUserId(), "kavitaplus-restricted")); + + return Ok((await _unitOfWork.UserRepository.GetUserTokenInfo())); + } } diff --git a/API/Controllers/VolumeController.cs b/API/Controllers/VolumeController.cs index 5d23336b4..db1381d9d 100644 --- a/API/Controllers/VolumeController.cs +++ b/API/Controllers/VolumeController.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -9,6 +11,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; +#nullable enable public class VolumeController : BaseApiController { @@ -23,13 +26,15 @@ public class VolumeController : BaseApiController _eventHub = eventHub; } + /// + /// Returns the appropriate Volume + /// + /// + /// [HttpGet] - public async Task> GetVolume(int volumeId) + public async Task> GetVolume(int volumeId) { - var volume = - await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId()); - - return Ok(volume); + return Ok(await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId())); } [Authorize(Policy = "RequireAdminRole")] @@ -39,7 +44,7 @@ public class VolumeController : BaseApiController var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters | VolumeIncludes.People | VolumeIncludes.Tags); if (volume == null) - return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); _unitOfWork.VolumeRepository.Remove(volume); @@ -51,4 +56,29 @@ public class VolumeController : BaseApiController return Ok(false); } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("multiple")] + public async Task> DeleteMultipleVolumes(int[] volumesIds) + { + var volumes = await _unitOfWork.VolumeRepository.GetVolumesById(volumesIds); + if (volumes.Count != volumesIds.Length) + { + return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); + } + + _unitOfWork.VolumeRepository.Remove(volumes); + + if (!await _unitOfWork.CommitAsync()) + { + return Ok(false); + } + + foreach (var volume in volumes) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false); + } + + return Ok(true); + } } diff --git a/API/DTOs/Account/AgeRestrictionDto.cs b/API/DTOs/Account/AgeRestrictionDto.cs index 0aaec9b97..6505bdbff 100644 --- a/API/DTOs/Account/AgeRestrictionDto.cs +++ b/API/DTOs/Account/AgeRestrictionDto.cs @@ -2,15 +2,15 @@ namespace API.DTOs.Account; -public class AgeRestrictionDto +public sealed record AgeRestrictionDto { /// /// The maximum age rating a user has access to. -1 if not applicable /// - public required AgeRating AgeRating { get; set; } = AgeRating.NotApplicable; + public required AgeRating AgeRating { get; init; } = AgeRating.NotApplicable; /// /// Are Unknowns explicitly allowed against age rating /// /// Unknown is always lowest and default age rating. Setting this to false will ensure Teen age rating applies and unknowns are still filtered - public required bool IncludeUnknowns { get; set; } = false; + public required bool IncludeUnknowns { get; init; } = false; } diff --git a/API/DTOs/Account/AniListUpdateDto.cs b/API/DTOs/Account/AniListUpdateDto.cs deleted file mode 100644 index d51a1dc0d..000000000 --- a/API/DTOs/Account/AniListUpdateDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace API.DTOs.Account; - -public class AniListUpdateDto -{ - public string Token { get; set; } -} diff --git a/API/DTOs/Account/ConfirmEmailDto.cs b/API/DTOs/Account/ConfirmEmailDto.cs index 2f5849e74..413f9f34a 100644 --- a/API/DTOs/Account/ConfirmEmailDto.cs +++ b/API/DTOs/Account/ConfirmEmailDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ConfirmEmailDto +public sealed record ConfirmEmailDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmEmailUpdateDto.cs b/API/DTOs/Account/ConfirmEmailUpdateDto.cs index 42abb1295..2a0738e35 100644 --- a/API/DTOs/Account/ConfirmEmailUpdateDto.cs +++ b/API/DTOs/Account/ConfirmEmailUpdateDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ConfirmEmailUpdateDto +public sealed record ConfirmEmailUpdateDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmMigrationEmailDto.cs b/API/DTOs/Account/ConfirmMigrationEmailDto.cs index efb42b8fd..cdfc1505c 100644 --- a/API/DTOs/Account/ConfirmMigrationEmailDto.cs +++ b/API/DTOs/Account/ConfirmMigrationEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class ConfirmMigrationEmailDto +public sealed record ConfirmMigrationEmailDto { public string Email { get; set; } = default!; public string Token { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmPasswordResetDto.cs b/API/DTOs/Account/ConfirmPasswordResetDto.cs index 16dd86f9a..00aff301b 100644 --- a/API/DTOs/Account/ConfirmPasswordResetDto.cs +++ b/API/DTOs/Account/ConfirmPasswordResetDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ConfirmPasswordResetDto +public sealed record ConfirmPasswordResetDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs index 112013053..c12bebc2b 100644 --- a/API/DTOs/Account/InviteUserDto.cs +++ b/API/DTOs/Account/InviteUserDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Account; -public class InviteUserDto +public sealed record InviteUserDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/InviteUserResponse.cs b/API/DTOs/Account/InviteUserResponse.cs index a7e0d86ea..ed16bd05e 100644 --- a/API/DTOs/Account/InviteUserResponse.cs +++ b/API/DTOs/Account/InviteUserResponse.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class InviteUserResponse +public sealed record InviteUserResponse { /// /// Email link used to setup the user account diff --git a/API/DTOs/Account/LoginDto.cs b/API/DTOs/Account/LoginDto.cs index fe8fce088..97338640b 100644 --- a/API/DTOs/Account/LoginDto.cs +++ b/API/DTOs/Account/LoginDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.Account; #nullable enable -public class LoginDto +public sealed record LoginDto { public string Username { get; init; } = default!; public string Password { get; set; } = default!; diff --git a/API/DTOs/Account/MigrateUserEmailDto.cs b/API/DTOs/Account/MigrateUserEmailDto.cs index 60d042165..4630c510f 100644 --- a/API/DTOs/Account/MigrateUserEmailDto.cs +++ b/API/DTOs/Account/MigrateUserEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class MigrateUserEmailDto +public sealed record MigrateUserEmailDto { public string Email { get; set; } = default!; public string Username { get; set; } = default!; diff --git a/API/DTOs/Account/ResetPasswordDto.cs b/API/DTOs/Account/ResetPasswordDto.cs index 51a195131..545ca5ba6 100644 --- a/API/DTOs/Account/ResetPasswordDto.cs +++ b/API/DTOs/Account/ResetPasswordDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ResetPasswordDto +public sealed record ResetPasswordDto { /// /// The Username of the User diff --git a/API/DTOs/Account/TokenRequestDto.cs b/API/DTOs/Account/TokenRequestDto.cs index 85ab9f87a..5c798721c 100644 --- a/API/DTOs/Account/TokenRequestDto.cs +++ b/API/DTOs/Account/TokenRequestDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class TokenRequestDto +public sealed record TokenRequestDto { public string Token { get; init; } = default!; public string RefreshToken { get; init; } = default!; diff --git a/API/DTOs/Account/UpdateAgeRestrictionDto.cs b/API/DTOs/Account/UpdateAgeRestrictionDto.cs index ef6be1bba..2fa9c89d2 100644 --- a/API/DTOs/Account/UpdateAgeRestrictionDto.cs +++ b/API/DTOs/Account/UpdateAgeRestrictionDto.cs @@ -3,7 +3,7 @@ using API.Entities.Enums; namespace API.DTOs.Account; -public class UpdateAgeRestrictionDto +public sealed record UpdateAgeRestrictionDto { [Required] public AgeRating AgeRating { get; set; } diff --git a/API/DTOs/Account/UpdateEmailDto.cs b/API/DTOs/Account/UpdateEmailDto.cs index eac06be53..873862ba1 100644 --- a/API/DTOs/Account/UpdateEmailDto.cs +++ b/API/DTOs/Account/UpdateEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class UpdateEmailDto +public sealed record UpdateEmailDto { public string Email { get; set; } = default!; public string Password { get; set; } = default!; diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs index ef19973f5..0cb0eaf66 100644 --- a/API/DTOs/Account/UpdateUserDto.cs +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -2,13 +2,18 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Account; +#nullable enable -public record UpdateUserDto +public sealed record UpdateUserDto { + /// public int UserId { get; set; } + /// public string Username { get; set; } = default!; + /// /// List of Roles to assign to user. If admin not present, Pleb will be applied. /// If admin present, all libraries will be granted access and will ignore those from DTO. + /// public IList Roles { get; init; } = default!; /// /// A list of libraries to grant access to @@ -18,8 +23,6 @@ public record UpdateUserDto /// An Age Rating which will limit the account to seeing everything equal to or below said rating. /// public AgeRestrictionDto AgeRestriction { get; init; } = default!; - /// - /// Email of the user - /// + /// public string? Email { get; set; } = default!; } diff --git a/API/DTOs/BulkActionDto.cs b/API/DTOs/BulkActionDto.cs index d3ce75293..c26a73e9c 100644 --- a/API/DTOs/BulkActionDto.cs +++ b/API/DTOs/BulkActionDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class BulkActionDto +public sealed record BulkActionDto { public List Ids { get; set; } /** diff --git a/API/DTOs/ChapterDetailPlusDto.cs b/API/DTOs/ChapterDetailPlusDto.cs new file mode 100644 index 000000000..d99482e55 --- /dev/null +++ b/API/DTOs/ChapterDetailPlusDto.cs @@ -0,0 +1,14 @@ +#nullable enable +using System.Collections.Generic; +using API.DTOs.SeriesDetail; + +namespace API.DTOs; + +public sealed record ChapterDetailPlusDto +{ + public float Rating { get; set; } + public bool HasBeenRated { get; set; } + + public IList Reviews { get; set; } = []; + public IList Ratings { get; set; } = []; +} diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 634ced4e9..85624b51c 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; using API.Entities.Interfaces; namespace API.DTOs; +#nullable enable /// /// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying @@ -12,37 +14,24 @@ namespace API.DTOs; /// public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage { + /// public int Id { get; init; } - /// - /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If special, will be special name. - /// - /// This can be something like 19.HU or Alpha as some comics are like this + /// public string Range { get; init; } = default!; - /// - /// Smallest number of the Range. - /// + /// [Obsolete("Use MinNumber and MaxNumber instead")] public string Number { get; init; } = default!; - /// - /// This may be 0 under the circumstance that the Issue is "Alpha" or other non-standard numbers. - /// + /// public float MinNumber { get; init; } + /// public float MaxNumber { get; init; } - /// - /// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden. - /// + /// public float SortOrder { get; set; } - /// - /// Total number of pages in all MangaFiles - /// + /// public int Pages { get; init; } - /// - /// If this Chapter contains files that could only be identified as Series or has Special Identifier from filename - /// + /// public bool IsSpecial { get; init; } - /// - /// Used for books/specials to display custom title. For non-specials/books, will be set to - /// + /// public string Title { get; set; } = default!; /// /// The files that represent this Chapter @@ -60,46 +49,25 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage /// The last time a chapter was read by current authenticated user /// public DateTime LastReadingProgress { get; set; } - /// - /// If the Cover Image is locked for this entity - /// + /// public bool CoverImageLocked { get; set; } - /// - /// Volume Id this Chapter belongs to - /// + /// public int VolumeId { get; init; } - /// - /// When chapter was created - /// + /// public DateTime CreatedUtc { get; set; } + /// public DateTime LastModifiedUtc { get; set; } - /// - /// When chapter was created in local server time - /// - /// This is required for Tachiyomi Extension + /// public DateTime Created { get; set; } - /// - /// When the chapter was released. - /// - /// Metadata field + /// public DateTime ReleaseDate { get; init; } - /// - /// Title of the Chapter/Issue - /// - /// Metadata field + /// public string TitleName { get; set; } = default!; - /// - /// Summary of the Chapter - /// - /// This is not set normally, only for Series Detail + /// public string Summary { get; init; } = default!; - /// - /// Age Rating for the issue/chapter - /// + /// public AgeRating AgeRating { get; init; } - /// - /// Total words in a Chapter (books only) - /// + /// public long WordCount { get; set; } = 0L; /// /// Formatted Volume title ie) Volume 2. @@ -112,14 +80,9 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage public int MaxHoursToRead { get; set; } /// public float AvgHoursToRead { get; set; } - /// - /// Comma-separated link of urls to external services that have some relation to the Chapter - /// + /// public string WebLinks { get; set; } - /// - /// ISBN-13 (usually) of the Chapter - /// - /// This is guaranteed to be Valid + /// public string ISBN { get; set; } #region Metadata @@ -145,51 +108,60 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage /// public ICollection Tags { get; set; } = new List(); public PublicationStatus PublicationStatus { get; set; } - /// - /// Language for the Chapter/Issue - /// + /// public string? Language { get; set; } - /// - /// Number in the TotalCount of issues - /// + /// public int Count { get; set; } - /// - /// Total number of issues for the series - /// + /// public int TotalCount { get; set; } + /// public bool LanguageLocked { get; set; } + /// public bool SummaryLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override AgeRating - /// + /// public bool AgeRatingLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override PublicationStatus - /// public bool PublicationStatusLocked { get; set; } + /// public bool GenresLocked { get; set; } + /// public bool TagsLocked { get; set; } + /// public bool WriterLocked { get; set; } + /// public bool CharacterLocked { get; set; } + /// public bool ColoristLocked { get; set; } + /// public bool EditorLocked { get; set; } + /// public bool InkerLocked { get; set; } + /// public bool ImprintLocked { get; set; } + /// public bool LettererLocked { get; set; } + /// public bool PencillerLocked { get; set; } + /// public bool PublisherLocked { get; set; } + /// public bool TranslatorLocked { get; set; } + /// public bool TeamLocked { get; set; } + /// public bool LocationLocked { get; set; } + /// public bool CoverArtistLocked { get; set; } public bool ReleaseYearLocked { get; set; } #endregion - public string CoverImage { get; set; } - public string PrimaryColor { get; set; } - public string SecondaryColor { get; set; } + /// + public string? CoverImage { get; set; } + /// + public string? PrimaryColor { get; set; } = string.Empty; + /// + public string? SecondaryColor { get; set; } = string.Empty; public void ResetColorScape() { diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs index cde0c1c14..0634b5d83 100644 --- a/API/DTOs/Collection/AppUserCollectionDto.cs +++ b/API/DTOs/Collection/AppUserCollectionDto.cs @@ -6,52 +6,52 @@ using API.Services.Plus; namespace API.DTOs.Collection; #nullable enable -public class AppUserCollectionDto : IHasCoverImage +public sealed record AppUserCollectionDto : IHasCoverImage { public int Id { get; init; } - public string Title { get; set; } = default!; - public string Summary { get; set; } = default!; - public bool Promoted { get; set; } - public AgeRating AgeRating { get; set; } + public string Title { get; init; } = default!; + public string? Summary { get; init; } = default!; + public bool Promoted { get; init; } + public AgeRating AgeRating { get; init; } /// /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. /// public string? CoverImage { get; set; } = string.Empty; - public string PrimaryColor { get; set; } = string.Empty; - public string SecondaryColor { get; set; } = string.Empty; - public bool CoverImageLocked { get; set; } + public string? PrimaryColor { get; set; } = string.Empty; + public string? SecondaryColor { get; set; } = string.Empty; + public bool CoverImageLocked { get; init; } /// /// Number of Series in the Collection /// - public int ItemCount { get; set; } + public int ItemCount { get; init; } /// /// Owner of the Collection /// - public string? Owner { get; set; } + public string? Owner { get; init; } /// /// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections) /// - public DateTime LastSyncUtc { get; set; } + public DateTime LastSyncUtc { get; init; } /// /// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote /// - public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita; + public ScrobbleProvider Source { get; init; } = ScrobbleProvider.Kavita; /// /// For Non-Kavita sourced collections, the url to sync from /// - public string? SourceUrl { get; set; } + public string? SourceUrl { get; init; } /// /// Total number of items as of the last sync. Not applicable for Kavita managed collections. /// - public int TotalSourceCount { get; set; } + public int TotalSourceCount { get; init; } /// /// A
separated string of all missing series ///
- public string? MissingSeriesFromSource { get; set; } + public string? MissingSeriesFromSource { get; init; } public void ResetColorScape() { diff --git a/API/DTOs/Collection/MalStackDto.cs b/API/DTOs/Collection/MalStackDto.cs index 3144f6c72..d9d902e88 100644 --- a/API/DTOs/Collection/MalStackDto.cs +++ b/API/DTOs/Collection/MalStackDto.cs @@ -1,4 +1,5 @@ namespace API.DTOs.Collection; +#nullable enable /// /// Represents an Interest Stack from MAL diff --git a/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs b/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs index 1d078959d..0a2270fbf 100644 --- a/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.CollectionTags; -public class CollectionTagBulkAddDto +public sealed record CollectionTagBulkAddDto { /// /// Collection Tag Id diff --git a/API/DTOs/CollectionTags/CollectionTagDto.cs b/API/DTOs/CollectionTags/CollectionTagDto.cs index ec9939ebd..911622051 100644 --- a/API/DTOs/CollectionTags/CollectionTagDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagDto.cs @@ -3,15 +3,21 @@ namespace API.DTOs.CollectionTags; [Obsolete("Use AppUserCollectionDto")] -public class CollectionTagDto +public sealed record CollectionTagDto { + /// public int Id { get; set; } + /// public string Title { get; set; } = default!; + /// public string Summary { get; set; } = default!; + /// public bool Promoted { get; set; } /// /// The cover image string. This is used on Frontend to show or hide the Cover Image /// + /// public string CoverImage { get; set; } = default!; + /// public bool CoverImageLocked { get; set; } } diff --git a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs index 19e9a11e2..139834a60 100644 --- a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs +++ b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs @@ -4,7 +4,7 @@ using API.DTOs.Collection; namespace API.DTOs.CollectionTags; -public class UpdateSeriesForTagDto +public sealed record UpdateSeriesForTagDto { public AppUserCollectionDto Tag { get; init; } = default!; public IEnumerable SeriesIdsToRemove { get; init; } = default!; diff --git a/API/DTOs/ColorScape.cs b/API/DTOs/ColorScape.cs index 39d1446dd..5351f2351 100644 --- a/API/DTOs/ColorScape.cs +++ b/API/DTOs/ColorScape.cs @@ -1,9 +1,10 @@ namespace API.DTOs; +#nullable enable /// /// A primary and secondary color /// -public class ColorScape +public sealed record ColorScape { public required string? Primary { get; set; } public required string? Secondary { get; set; } diff --git a/API/DTOs/CopySettingsFromLibraryDto.cs b/API/DTOs/CopySettingsFromLibraryDto.cs index ee75f7422..5ca5ead51 100644 --- a/API/DTOs/CopySettingsFromLibraryDto.cs +++ b/API/DTOs/CopySettingsFromLibraryDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class CopySettingsFromLibraryDto +public sealed record CopySettingsFromLibraryDto { public int SourceLibraryId { get; set; } public List TargetLibraryIds { get; set; } diff --git a/API/DTOs/CoverDb/CoverDbAuthor.cs b/API/DTOs/CoverDb/CoverDbAuthor.cs index 2f023398a..ca924801f 100644 --- a/API/DTOs/CoverDb/CoverDbAuthor.cs +++ b/API/DTOs/CoverDb/CoverDbAuthor.cs @@ -3,7 +3,7 @@ using YamlDotNet.Serialization; namespace API.DTOs.CoverDb; -public class CoverDbAuthor +public sealed record CoverDbAuthor { [YamlMember(Alias = "name", ApplyNamingConventions = false)] public string Name { get; set; } diff --git a/API/DTOs/CoverDb/CoverDbPeople.cs b/API/DTOs/CoverDb/CoverDbPeople.cs index c0f5e327e..2e825eac7 100644 --- a/API/DTOs/CoverDb/CoverDbPeople.cs +++ b/API/DTOs/CoverDb/CoverDbPeople.cs @@ -3,7 +3,7 @@ using YamlDotNet.Serialization; namespace API.DTOs.CoverDb; -public class CoverDbPeople +public sealed record CoverDbPeople { [YamlMember(Alias = "people", ApplyNamingConventions = false)] public List People { get; set; } = new List(); diff --git a/API/DTOs/CoverDb/CoverDbPersonIds.cs b/API/DTOs/CoverDb/CoverDbPersonIds.cs index 9c59415e6..5816bb479 100644 --- a/API/DTOs/CoverDb/CoverDbPersonIds.cs +++ b/API/DTOs/CoverDb/CoverDbPersonIds.cs @@ -3,7 +3,7 @@ namespace API.DTOs.CoverDb; #nullable enable -public class CoverDbPersonIds +public sealed record CoverDbPersonIds { [YamlMember(Alias = "hardcover_id", ApplyNamingConventions = false)] public string? HardcoverId { get; set; } = null; diff --git a/API/DTOs/Dashboard/DashboardStreamDto.cs b/API/DTOs/Dashboard/DashboardStreamDto.cs index 59e5f4f7d..297a706b1 100644 --- a/API/DTOs/Dashboard/DashboardStreamDto.cs +++ b/API/DTOs/Dashboard/DashboardStreamDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.Dashboard; -public class DashboardStreamDto +public sealed record DashboardStreamDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/API/DTOs/Dashboard/GroupedSeriesDto.cs b/API/DTOs/Dashboard/GroupedSeriesDto.cs index 3b283de34..940e42c40 100644 --- a/API/DTOs/Dashboard/GroupedSeriesDto.cs +++ b/API/DTOs/Dashboard/GroupedSeriesDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Dashboard; /// /// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section /// -public class GroupedSeriesDto +public sealed record GroupedSeriesDto { public string SeriesName { get; set; } = default!; public int SeriesId { get; set; } diff --git a/API/DTOs/Dashboard/RecentlyAddedItemDto.cs b/API/DTOs/Dashboard/RecentlyAddedItemDto.cs index 2e5658e2e..bb0360b30 100644 --- a/API/DTOs/Dashboard/RecentlyAddedItemDto.cs +++ b/API/DTOs/Dashboard/RecentlyAddedItemDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Dashboard; /// /// A mesh of data for Recently added volume/chapters /// -public class RecentlyAddedItemDto +public sealed record RecentlyAddedItemDto { public string SeriesName { get; set; } = default!; public int SeriesId { get; set; } diff --git a/API/DTOs/Dashboard/SmartFilterDto.cs b/API/DTOs/Dashboard/SmartFilterDto.cs index b23a74c69..c1bc4d7e1 100644 --- a/API/DTOs/Dashboard/SmartFilterDto.cs +++ b/API/DTOs/Dashboard/SmartFilterDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Dashboard; -public class SmartFilterDto +public sealed record SmartFilterDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs b/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs index c2320f1a9..476a0732e 100644 --- a/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs +++ b/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Dashboard; -public class UpdateDashboardStreamPositionDto +public sealed record UpdateDashboardStreamPositionDto { public int FromPosition { get; set; } public int ToPosition { get; set; } diff --git a/API/DTOs/Dashboard/UpdateStreamPositionDto.cs b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs index f9005a585..8de0ffa6f 100644 --- a/API/DTOs/Dashboard/UpdateStreamPositionDto.cs +++ b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Dashboard; -public class UpdateStreamPositionDto +public sealed record UpdateStreamPositionDto { public int FromPosition { get; set; } public int ToPosition { get; set; } diff --git a/API/DTOs/DeleteChaptersDto.cs b/API/DTOs/DeleteChaptersDto.cs index cbd21df36..9fad2f1fb 100644 --- a/API/DTOs/DeleteChaptersDto.cs +++ b/API/DTOs/DeleteChaptersDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class DeleteChaptersDto +public sealed record DeleteChaptersDto { public IList ChapterIds { get; set; } = default!; } diff --git a/API/DTOs/DeleteSeriesDto.cs b/API/DTOs/DeleteSeriesDto.cs index 12687fc25..ec9ba0c68 100644 --- a/API/DTOs/DeleteSeriesDto.cs +++ b/API/DTOs/DeleteSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class DeleteSeriesDto +public sealed record DeleteSeriesDto { public IList SeriesIds { get; set; } = default!; } diff --git a/API/DTOs/Device/CreateDeviceDto.cs b/API/DTOs/Device/CreateDeviceDto.cs index 7e59483fa..a8fdb6bc9 100644 --- a/API/DTOs/Device/CreateDeviceDto.cs +++ b/API/DTOs/Device/CreateDeviceDto.cs @@ -3,7 +3,7 @@ using API.Entities.Enums.Device; namespace API.DTOs.Device; -public class CreateDeviceDto +public sealed record CreateDeviceDto { [Required] public string Name { get; set; } = default!; diff --git a/API/DTOs/Device/DeviceDto.cs b/API/DTOs/Device/DeviceDto.cs index b2e83e6fc..42140dcc1 100644 --- a/API/DTOs/Device/DeviceDto.cs +++ b/API/DTOs/Device/DeviceDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Device; /// /// A Device is an entity that can receive data from Kavita (kindle) /// -public class DeviceDto +public sealed record DeviceDto { /// /// The device Id diff --git a/API/DTOs/Device/SendSeriesToDeviceDto.cs b/API/DTOs/Device/SendSeriesToDeviceDto.cs index a0a907464..58ce2293b 100644 --- a/API/DTOs/Device/SendSeriesToDeviceDto.cs +++ b/API/DTOs/Device/SendSeriesToDeviceDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Device; -public class SendSeriesToDeviceDto +public sealed record SendSeriesToDeviceDto { public int DeviceId { get; set; } public int SeriesId { get; set; } diff --git a/API/DTOs/Device/SendToDeviceDto.cs b/API/DTOs/Device/SendToDeviceDto.cs index fd88eaf59..a7a4dc0ff 100644 --- a/API/DTOs/Device/SendToDeviceDto.cs +++ b/API/DTOs/Device/SendToDeviceDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Device; -public class SendToDeviceDto +public sealed record SendToDeviceDto { public int DeviceId { get; set; } public IReadOnlyList ChapterIds { get; set; } = default!; diff --git a/API/DTOs/Device/UpdateDeviceDto.cs b/API/DTOs/Device/UpdateDeviceDto.cs index d28d372c3..2c3e72ea1 100644 --- a/API/DTOs/Device/UpdateDeviceDto.cs +++ b/API/DTOs/Device/UpdateDeviceDto.cs @@ -3,7 +3,7 @@ using API.Entities.Enums.Device; namespace API.DTOs.Device; -public class UpdateDeviceDto +public sealed record UpdateDeviceDto { [Required] public int Id { get; set; } diff --git a/API/DTOs/Downloads/DownloadBookmarkDto.cs b/API/DTOs/Downloads/DownloadBookmarkDto.cs index 5b7240b68..00f763dac 100644 --- a/API/DTOs/Downloads/DownloadBookmarkDto.cs +++ b/API/DTOs/Downloads/DownloadBookmarkDto.cs @@ -4,7 +4,7 @@ using API.DTOs.Reader; namespace API.DTOs.Downloads; -public class DownloadBookmarkDto +public sealed record DownloadBookmarkDto { [Required] public IEnumerable Bookmarks { get; set; } = default!; diff --git a/API/DTOs/Email/ConfirmationEmailDto.cs b/API/DTOs/Email/ConfirmationEmailDto.cs index 1a48c9974..197395794 100644 --- a/API/DTOs/Email/ConfirmationEmailDto.cs +++ b/API/DTOs/Email/ConfirmationEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class ConfirmationEmailDto +public sealed record ConfirmationEmailDto { public string InvitingUser { get; init; } = default!; public string EmailAddress { get; init; } = default!; diff --git a/API/DTOs/Email/EmailHistoryDto.cs b/API/DTOs/Email/EmailHistoryDto.cs new file mode 100644 index 000000000..c2968d091 --- /dev/null +++ b/API/DTOs/Email/EmailHistoryDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace API.DTOs.Email; + +public sealed record EmailHistoryDto +{ + public long Id { get; set; } + public bool Sent { get; set; } + public DateTime SendDate { get; set; } = DateTime.UtcNow; + public string EmailTemplate { get; set; } + public string ErrorMessage { get; set; } + public string ToUserName { get; set; } + +} diff --git a/API/DTOs/Email/EmailMigrationDto.cs b/API/DTOs/Email/EmailMigrationDto.cs index f051e7337..5354afdaa 100644 --- a/API/DTOs/Email/EmailMigrationDto.cs +++ b/API/DTOs/Email/EmailMigrationDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class EmailMigrationDto +public sealed record EmailMigrationDto { public string EmailAddress { get; init; } = default!; public string Username { get; init; } = default!; diff --git a/API/DTOs/Email/EmailTestResultDto.cs b/API/DTOs/Email/EmailTestResultDto.cs index 263e725c4..9be868eab 100644 --- a/API/DTOs/Email/EmailTestResultDto.cs +++ b/API/DTOs/Email/EmailTestResultDto.cs @@ -3,7 +3,7 @@ /// /// Represents if Test Email Service URL was successful or not and if any error occured /// -public class EmailTestResultDto +public sealed record EmailTestResultDto { public bool Successful { get; set; } public string ErrorMessage { get; set; } = default!; diff --git a/API/DTOs/Email/PasswordResetEmailDto.cs b/API/DTOs/Email/PasswordResetEmailDto.cs index 06abba171..9fda066a9 100644 --- a/API/DTOs/Email/PasswordResetEmailDto.cs +++ b/API/DTOs/Email/PasswordResetEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class PasswordResetEmailDto +public sealed record PasswordResetEmailDto { public string EmailAddress { get; init; } = default!; public string ServerConfirmationLink { get; init; } = default!; diff --git a/API/DTOs/Email/SendToDto.cs b/API/DTOs/Email/SendToDto.cs index 1261d110c..eacd29449 100644 --- a/API/DTOs/Email/SendToDto.cs +++ b/API/DTOs/Email/SendToDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Email; -public class SendToDto +public sealed record SendToDto { public string DestinationEmail { get; set; } = default!; public IEnumerable FilePaths { get; set; } = default!; diff --git a/API/DTOs/Email/TestEmailDto.cs b/API/DTOs/Email/TestEmailDto.cs index 37c12ed30..44c11bd6c 100644 --- a/API/DTOs/Email/TestEmailDto.cs +++ b/API/DTOs/Email/TestEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class TestEmailDto +public sealed record TestEmailDto { public string Url { get; set; } = default!; } diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index 9205a7bba..cb3374838 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -5,7 +5,7 @@ using API.Entities.Enums; namespace API.DTOs.Filtering; #nullable enable -public class FilterDto +public sealed record FilterDto { /// /// The type of Formats you want to be returned. An empty list will return all formats back diff --git a/API/DTOs/Filtering/LanguageDto.cs b/API/DTOs/Filtering/LanguageDto.cs index bc7ebb5cc..dde85f07e 100644 --- a/API/DTOs/Filtering/LanguageDto.cs +++ b/API/DTOs/Filtering/LanguageDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Filtering; -public class LanguageDto +public sealed record LanguageDto { public required string IsoCode { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Filtering/PersonSortField.cs b/API/DTOs/Filtering/PersonSortField.cs new file mode 100644 index 000000000..5268a1bf9 --- /dev/null +++ b/API/DTOs/Filtering/PersonSortField.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Filtering; + +public enum PersonSortField +{ + Name = 1, + SeriesCount = 2, + ChapterCount = 3 +} diff --git a/API/DTOs/Filtering/Range.cs b/API/DTOs/Filtering/Range.cs index a75164fa3..e697f26e1 100644 --- a/API/DTOs/Filtering/Range.cs +++ b/API/DTOs/Filtering/Range.cs @@ -4,7 +4,7 @@ /// /// Represents a range between two int/float/double /// -public class Range +public sealed record Range { public T? Min { get; init; } public T? Max { get; init; } diff --git a/API/DTOs/Filtering/ReadStatus.cs b/API/DTOs/Filtering/ReadStatus.cs index eeb786714..81498ecb5 100644 --- a/API/DTOs/Filtering/ReadStatus.cs +++ b/API/DTOs/Filtering/ReadStatus.cs @@ -3,7 +3,7 @@ /// /// Represents the Reading Status. This is a flag and allows multiple statues /// -public class ReadStatus +public sealed record ReadStatus { public bool NotRead { get; set; } = true; public bool InProgress { get; set; } = true; diff --git a/API/DTOs/Filtering/SortOptions.cs b/API/DTOs/Filtering/SortOptions.cs index 00bf91675..18f2b17ea 100644 --- a/API/DTOs/Filtering/SortOptions.cs +++ b/API/DTOs/Filtering/SortOptions.cs @@ -3,8 +3,17 @@ /// /// Sorting Options for a query /// -public class SortOptions +public sealed record SortOptions { public SortField SortField { get; set; } public bool IsAscending { get; set; } = true; } + +/// +/// All Sorting Options for a query related to Person Entity +/// +public sealed record PersonSortOptions +{ + public PersonSortField SortField { get; set; } + public bool IsAscending { get; set; } = true; +} diff --git a/API/DTOs/Filtering/v2/DecodeFilterDto.cs b/API/DTOs/Filtering/v2/DecodeFilterDto.cs index 18dc166e7..db4c7ecce 100644 --- a/API/DTOs/Filtering/v2/DecodeFilterDto.cs +++ b/API/DTOs/Filtering/v2/DecodeFilterDto.cs @@ -3,7 +3,7 @@ /// /// For requesting an encoded filter to be decoded /// -public class DecodeFilterDto +public sealed record DecodeFilterDto { public string EncodedFilter { get; set; } } diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index 5323f2b48..246a92a90 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -56,5 +56,12 @@ public enum FilterField /// Last time User Read /// ReadLast = 32, - +} + +public enum PersonFilterField +{ + Role = 1, + Name = 2, + SeriesCount = 3, + ChapterCount = 4, } diff --git a/API/DTOs/Filtering/v2/FilterStatementDto.cs b/API/DTOs/Filtering/v2/FilterStatementDto.cs index a6192093e..8c99bd24c 100644 --- a/API/DTOs/Filtering/v2/FilterStatementDto.cs +++ b/API/DTOs/Filtering/v2/FilterStatementDto.cs @@ -1,8 +1,17 @@ -namespace API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse.Requests; -public class FilterStatementDto +namespace API.DTOs.Filtering.v2; + +public sealed record FilterStatementDto { public FilterComparison Comparison { get; set; } public FilterField Field { get; set; } public string Value { get; set; } } + +public sealed record PersonFilterStatementDto +{ + public FilterComparison Comparison { get; set; } + public PersonFilterField Field { get; set; } + public string Value { get; set; } +} diff --git a/API/DTOs/Filtering/v2/FilterV2Dto.cs b/API/DTOs/Filtering/v2/FilterV2Dto.cs index 5bc50ff2f..a247a17a6 100644 --- a/API/DTOs/Filtering/v2/FilterV2Dto.cs +++ b/API/DTOs/Filtering/v2/FilterV2Dto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Filtering.v2; /// /// Metadata filtering for v2 API only /// -public class FilterV2Dto +public sealed record FilterV2Dto { /// /// Not used in the UI. @@ -16,7 +16,7 @@ public class FilterV2Dto /// The name of the filter /// public string? Name { get; set; } - public ICollection Statements { get; set; } = new List(); + public ICollection Statements { get; set; } = []; public FilterCombination Combination { get; set; } = FilterCombination.And; public SortOptions? SortOptions { get; set; } diff --git a/API/DTOs/Jobs/JobDto.cs b/API/DTOs/Jobs/JobDto.cs index 648765a34..55419811f 100644 --- a/API/DTOs/Jobs/JobDto.cs +++ b/API/DTOs/Jobs/JobDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Jobs; -public class JobDto +public sealed record JobDto { /// /// Job Id diff --git a/API/DTOs/JumpBar/JumpKeyDto.cs b/API/DTOs/JumpBar/JumpKeyDto.cs index 5a98a85ca..8dc5b4a8e 100644 --- a/API/DTOs/JumpBar/JumpKeyDto.cs +++ b/API/DTOs/JumpBar/JumpKeyDto.cs @@ -3,7 +3,7 @@ /// /// Represents an individual button in a Jump Bar /// -public class JumpKeyDto +public sealed record JumpKeyDto { /// /// Number of items in this Key diff --git a/API/DTOs/KavitaLocale.cs b/API/DTOs/KavitaLocale.cs new file mode 100644 index 000000000..51868605f --- /dev/null +++ b/API/DTOs/KavitaLocale.cs @@ -0,0 +1,10 @@ +namespace API.DTOs; + +public sealed record KavitaLocale +{ + public string FileName { get; set; } // Key + public string RenderName { get; set; } + public float TranslationCompletion { get; set; } + public bool IsRtL { get; set; } + public string Hash { get; set; } // ETAG hash so I can run my own localization busting implementation +} diff --git a/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs new file mode 100644 index 000000000..c053bd34e --- /dev/null +++ b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs @@ -0,0 +1,6 @@ +namespace API.DTOs.KavitaPlus.Account; + +public sealed record AniListUpdateDto +{ + public string Token { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs b/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs new file mode 100644 index 000000000..340ad0f4c --- /dev/null +++ b/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs @@ -0,0 +1,16 @@ +using System; + +namespace API.DTOs.KavitaPlus.Account; + +/// +/// Represents information around a user's tokens and their status +/// +public sealed record UserTokenInfo +{ + public int UserId { get; set; } + public string Username { get; set; } + public bool IsAniListTokenSet { get; set; } + public bool IsAniListTokenValid { get; set; } + public DateTime AniListValidUntilUtc { get; set; } + public bool IsMalTokenSet { get; set; } +} diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs new file mode 100644 index 000000000..c05ff0567 --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs @@ -0,0 +1,17 @@ +using API.DTOs.Scrobbling; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; +#nullable enable + +/// +/// Used for matching and fetching metadata on a series +/// +public sealed record ExternalMetadataIdsDto +{ + public long? MalId { get; set; } + public int? AniListId { get; set; } + + public string? SeriesName { get; set; } + public string? LocalizedSeriesName { get; set; } + public PlusMediaFormat? PlusMediaFormat { get; set; } = DTOs.Scrobbling.PlusMediaFormat.Unknown; +} diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs new file mode 100644 index 000000000..a7359d69b --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using API.DTOs.Scrobbling; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; +#nullable enable + +/// +/// Represents a request to match some series from Kavita to an external id which K+ uses. +/// +public sealed record MatchSeriesRequestDto +{ + public required string SeriesName { get; set; } + public ICollection AlternativeNames { get; set; } = []; + public int Year { get; set; } = 0; + public string? Query { get; set; } + public int? AniListId { get; set; } + public long? MalId { get; set; } + public string? HardcoverId { get; set; } + public int? CbrId { get; set; } + public PlusMediaFormat Format { get; set; } +} diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs new file mode 100644 index 000000000..84e9bbf3e --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; +using API.DTOs.Scrobbling; +using API.DTOs.SeriesDetail; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; + +public sealed record SeriesDetailPlusApiDto +{ + public IEnumerable Recommendations { get; set; } + public IEnumerable Reviews { get; set; } + public IEnumerable Ratings { get; set; } + public ExternalSeriesDetailDto? Series { get; set; } + public int? AniListId { get; set; } + public long? MalId { get; set; } + public int? CbrId { get; set; } +} diff --git a/API/DTOs/License/EncryptLicenseDto.cs b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs similarity index 66% rename from API/DTOs/License/EncryptLicenseDto.cs rename to API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs index 97015c470..dd85dd063 100644 --- a/API/DTOs/License/EncryptLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs @@ -1,6 +1,7 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; +#nullable enable -public class EncryptLicenseDto +public sealed record EncryptLicenseDto { public required string License { get; set; } public required string InstallId { get; set; } diff --git a/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs new file mode 100644 index 000000000..2cd9b5896 --- /dev/null +++ b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs @@ -0,0 +1,35 @@ +using System; + +namespace API.DTOs.KavitaPlus.License; + +public sealed record LicenseInfoDto +{ + /// + /// If cancelled, will represent cancellation date. If not, will represent repayment date + /// + public DateTime ExpirationDate { get; set; } + /// + /// If cancelled or not + /// + public bool IsActive { get; set; } + /// + /// If will be or is cancelled + /// + public bool IsCancelled { get; set; } + /// + /// Is the installed version valid for Kavita+ (aka within 3 releases) + /// + public bool IsValidVersion { get; set; } + /// + /// The email on file + /// + public string RegisteredEmail { get; set; } + /// + /// Number of months user has been subscribed + /// + public int TotalMonthsSubbed { get; set; } + /// + /// A license is stored within Kavita + /// + public bool HasLicense { get; set; } +} diff --git a/API/DTOs/Account/LicenseValidDto.cs b/API/DTOs/KavitaPlus/License/LicenseValidDto.cs similarity index 57% rename from API/DTOs/Account/LicenseValidDto.cs rename to API/DTOs/KavitaPlus/License/LicenseValidDto.cs index f49420779..a7bd476ce 100644 --- a/API/DTOs/Account/LicenseValidDto.cs +++ b/API/DTOs/KavitaPlus/License/LicenseValidDto.cs @@ -1,6 +1,6 @@ -namespace API.DTOs.Account; +namespace API.DTOs.KavitaPlus.License; -public class LicenseValidDto +public sealed record LicenseValidDto { public required string License { get; set; } public required string InstallId { get; set; } diff --git a/API/DTOs/License/ResetLicenseDto.cs b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs similarity index 66% rename from API/DTOs/License/ResetLicenseDto.cs rename to API/DTOs/KavitaPlus/License/ResetLicenseDto.cs index f62d78870..d0fd9b666 100644 --- a/API/DTOs/License/ResetLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs @@ -1,6 +1,6 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; -public class ResetLicenseDto +public sealed record ResetLicenseDto { public required string License { get; set; } public required string InstallId { get; set; } diff --git a/API/DTOs/License/UpdateLicenseDto.cs b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs similarity index 78% rename from API/DTOs/License/UpdateLicenseDto.cs rename to API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs index b2803952c..28b47efbe 100644 --- a/API/DTOs/License/UpdateLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs @@ -1,6 +1,7 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; +#nullable enable -public class UpdateLicenseDto +public sealed record UpdateLicenseDto { /// /// License Key received from Kavita+ diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs new file mode 100644 index 000000000..c394cf8d4 --- /dev/null +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs @@ -0,0 +1,23 @@ +namespace API.DTOs.KavitaPlus.Manage; + +/// +/// Represents an option in the UI layer for Filtering +/// +public enum MatchStateOption +{ + All = 0, + Matched = 1, + NotMatched = 2, + Error = 3, + DontMatch = 4 +} + +public sealed record ManageMatchFilterDto +{ + public MatchStateOption MatchStateOption { get; set; } = MatchStateOption.All; + /// + /// Library Type in int form. -1 indicates to ignore the field. + /// + public int LibraryType { get; set; } = -1; + public string SearchTerm { get; set; } = string.Empty; +} diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs new file mode 100644 index 000000000..a51e63ee9 --- /dev/null +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace API.DTOs.KavitaPlus.Manage; + +public sealed record ManageMatchSeriesDto +{ + public SeriesDto Series { get; set; } + public bool IsMatched { get; set; } + public DateTime ValidUntilUtc { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs new file mode 100644 index 000000000..add9ca723 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using API.DTOs.SeriesDetail; + +namespace API.DTOs.KavitaPlus.Metadata; +#nullable enable + +/// +/// Information about an individual issue/chapter/book from Kavita+ +/// +public sealed record ExternalChapterDto +{ + public string Title { get; set; } + + public string IssueNumber { get; set; } + + public decimal? CriticRating { get; set; } + + public decimal? UserRating { get; set; } + + public string? Summary { get; set; } + + public IList? Writers { get; set; } + + public IList? Artists { get; set; } + + public DateTime? ReleaseDate { get; set; } + + public string? Publisher { get; set; } + + public string? CoverImageUrl { get; set; } + + public string? IssueUrl { get; set; } + + public IList CriticReviews { get; set; } + public IList UserReviews { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs new file mode 100644 index 000000000..6704bf697 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using API.DTOs.Recommendation; +using API.DTOs.Scrobbling; +using API.Services.Plus; + +namespace API.DTOs.KavitaPlus.Metadata; +#nullable enable + +/// +/// This is AniListSeries +/// +public sealed record ExternalSeriesDetailDto +{ + public string Name { get; set; } + public int? AniListId { get; set; } + public long? MALId { get; set; } + public int? CbrId { get; set; } + public IList Synonyms { get; set; } = []; + public PlusMediaFormat PlusMediaFormat { get; set; } + public string? SiteUrl { get; set; } + public string? CoverUrl { get; set; } + public IList Genres { get; set; } + public IList Staff { get; set; } + public IList Tags { get; set; } + public string? Summary { get; set; } + public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; + + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int AverageScore { get; set; } + /// AniList returns the total count of unique chapters, includes 1.1 for example + public int Chapters { get; set; } + /// AniList returns the total count of unique volumes, includes 1.1 for example + public int Volumes { get; set; } + public IList? Relations { get; set; } = []; + public IList? Characters { get; set; } = []; + + #region Comic Only + public string? Publisher { get; set; } + /// + /// Only from CBR for . Full metadata about issues + /// + public IList? ChapterDtos { get; set; } + #endregion + + +} diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs new file mode 100644 index 000000000..a9debabd1 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs @@ -0,0 +1,22 @@ +using API.Entities.Enums; + +namespace API.DTOs.KavitaPlus.Metadata; + +public sealed record MetadataFieldMappingDto +{ + public int Id { get; set; } + public MetadataFieldType SourceType { get; set; } + public MetadataFieldType DestinationType { get; set; } + /// + /// The string in the source + /// + public string SourceValue { get; set; } + /// + /// Write the string as this in the Destination (can also just be the Source) + /// + public string DestinationValue { get; set; } + /// + /// If true, the tag will be Moved over vs Copied over + /// + public bool ExcludeFromSource { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs new file mode 100644 index 000000000..e9f6614bc --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; +using API.Entities.MetadataMatching; +using NotImplementedException = System.NotImplementedException; + +namespace API.DTOs.KavitaPlus.Metadata; + + +public sealed record MetadataSettingsDto +{ + /// + /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed + /// + public bool Enabled { get; set; } + + /// + /// Allow the Summary to be written + /// + public bool EnableSummary { get; set; } + /// + /// Allow Publication status to be derived and updated + /// + public bool EnablePublicationStatus { get; set; } + /// + /// Allow Relationships between series to be set + /// + public bool EnableRelationships { get; set; } + /// + /// Allow People to be created (including downloading images) + /// + public bool EnablePeople { get; set; } + /// + /// Allow Start date to be set within the Series + /// + public bool EnableStartDate { get; set; } + /// + /// Allow setting the Localized name + /// + public bool EnableLocalizedName { get; set; } + /// + /// Allow setting the cover image + /// + public bool EnableCoverImage { get; set; } + + #region Chapter Metadata + /// + /// Allow Summary to be set within Chapter/Issue + /// + public bool EnableChapterSummary { get; set; } + /// + /// Allow Release Date to be set within Chapter/Issue + /// + public bool EnableChapterReleaseDate { get; set; } + /// + /// Allow Title to be set within Chapter/Issue + /// + public bool EnableChapterTitle { get; set; } + /// + /// Allow Publisher to be set within Chapter/Issue + /// + public bool EnableChapterPublisher { get; set; } + /// + /// Allow setting the cover image for the Chapter/Issue + /// + public bool EnableChapterCoverImage { get; set; } + #endregion + + // Need to handle the Genre/tags stuff + public bool EnableGenres { get; set; } = true; + public bool EnableTags { get; set; } = true; + + /// + /// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names. + /// + public bool FirstLastPeopleNaming { get; set; } + + /// + /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. + /// + public Dictionary AgeRatingMappings { get; set; } + + /// + /// A list of rules that allow mapping a genre/tag to another genre/tag + /// + public List FieldMappings { get; set; } + /// + /// A list of overrides that will enable writing to locked fields + /// + public List Overrides { get; set; } + + /// + /// Do not allow any Genre/Tag in this list to be written to Kavita + /// + public List Blacklist { get; set; } + /// + /// Only allow these Tags to be written to Kavita + /// + public List Whitelist { get; set; } + /// + /// Which Roles to allow metadata downloading for + /// + public List PersonRoles { get; set; } + + + /// + /// Override list contains this field + /// + /// + /// + public bool HasOverride(MetadataSettingField field) + { + return Overrides.Contains(field); + } + + /// + /// If this Person role is allowed to be written + /// + /// + /// + public bool IsPersonAllowed(PersonRole character) + { + return PersonRoles.Contains(character); + } +} diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs new file mode 100644 index 000000000..2b57548cd --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs @@ -0,0 +1,19 @@ +namespace API.DTOs.KavitaPlus.Metadata; +#nullable enable + +public enum CharacterRole +{ + Main = 0, + Supporting = 1, + Background = 2 +} + + +public sealed record SeriesCharacter +{ + public string Name { get; set; } + public required string Description { get; set; } + public required string Url { get; set; } + public string? ImageUrl { get; set; } + public CharacterRole Role { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs new file mode 100644 index 000000000..0b1f619a2 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs @@ -0,0 +1,24 @@ +using API.DTOs.Scrobbling; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Services.Plus; + +namespace API.DTOs.KavitaPlus.Metadata; + +public sealed record ALMediaTitle +{ + public string? EnglishTitle { get; set; } + public string RomajiTitle { get; set; } + public string NativeTitle { get; set; } + public string PreferredTitle { get; set; } +} + +public sealed record SeriesRelationship +{ + public int AniListId { get; set; } + public int? MalId { get; set; } + public ALMediaTitle SeriesName { get; set; } + public RelationKind Relation { get; set; } + public ScrobbleProvider Provider { get; set; } + public PlusMediaFormat PlusMediaFormat { get; set; } = PlusMediaFormat.Manga; +} diff --git a/API/DTOs/Koreader/KoreaderBookDto.cs b/API/DTOs/Koreader/KoreaderBookDto.cs new file mode 100644 index 000000000..b66b7da3a --- /dev/null +++ b/API/DTOs/Koreader/KoreaderBookDto.cs @@ -0,0 +1,33 @@ +using API.DTOs.Progress; + +namespace API.DTOs.Koreader; + +/// +/// This is the interface for receiving and sending updates to Koreader. The only fields +/// that are actually used are the Document and Progress fields. +/// +public class KoreaderBookDto +{ + /// + /// This is the Koreader hash of the book. It is used to identify the book. + /// + public string Document { get; set; } + /// + /// A randomly generated id from the koreader device. Only used to maintain the Koreader interface. + /// + public string Device_id { get; set; } + /// + /// The Koreader device name. Only used to maintain the Koreader interface. + /// + public string Device { get; set; } + /// + /// Percent progress of the book. Only used to maintain the Koreader interface. + /// + public float Percentage { get; set; } + /// + /// An XPath string read by Koreader to determine the location within the epub. + /// Essentially, it is Koreader's equivalent to ProgressDto.BookScrollId. + /// + /// + public string Progress { get; set; } +} diff --git a/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs b/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs new file mode 100644 index 000000000..52a1d6cbd --- /dev/null +++ b/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs @@ -0,0 +1,15 @@ +using System; + +namespace API.DTOs.Koreader; + +public class KoreaderProgressUpdateDto +{ + /// + /// This is the Koreader hash of the book. It is used to identify the book. + /// + public string Document { get; set; } + /// + /// UTC Timestamp to return to KOReader + /// + public DateTime Timestamp { get; set; } +} diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index c8c85063e..bd72ad2f0 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -1,12 +1,11 @@ using System; -using System.Collections; using System.Collections.Generic; using API.Entities.Enums; namespace API.DTOs; #nullable enable -public class LibraryDto +public sealed record LibraryDto { public int Id { get; init; } public string? Name { get; init; } @@ -61,4 +60,18 @@ public class LibraryDto /// A set of globs that will exclude matching content from being scanned /// public ICollection ExcludePatterns { get; set; } + /// + /// Allow any series within this Library to download metadata. + /// + /// This does not exclude the library from being linked to wrt Series Relationships + /// Requires a valid LicenseKey + public bool AllowMetadataMatching { get; set; } = true; + /// + /// Allow Kavita to read metadata (ComicInfo.xml, Epub, PDF) + /// + public bool EnableMetadata { get; set; } = true; + /// + /// Should Kavita remove sort articles "The" for the sort name + /// + public bool RemovePrefixForSortName { get; set; } = false; } diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index 3f0983067..23bb37467 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -2,8 +2,9 @@ using API.Entities.Enums; namespace API.DTOs; +#nullable enable -public class MangaFileDto +public sealed record MangaFileDto { public int Id { get; init; } /// diff --git a/API/DTOs/MediaErrors/MediaErrorDto.cs b/API/DTOs/MediaErrors/MediaErrorDto.cs index bfaf57124..b77ee88be 100644 --- a/API/DTOs/MediaErrors/MediaErrorDto.cs +++ b/API/DTOs/MediaErrors/MediaErrorDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.MediaErrors; -public class MediaErrorDto +public sealed record MediaErrorDto { /// /// Format Type (RAR, ZIP, 7Zip, Epub, PDF) diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index 7b750b32f..f5f24b284 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -8,7 +8,7 @@ namespace API.DTOs; /// /// Represents a member of a Kavita server. /// -public class MemberDto +public sealed record MemberDto { public int Id { get; init; } public string? Username { get; init; } diff --git a/API/DTOs/Metadata/AgeRatingDto.cs b/API/DTOs/Metadata/AgeRatingDto.cs index 07523c3fe..bfa835ef5 100644 --- a/API/DTOs/Metadata/AgeRatingDto.cs +++ b/API/DTOs/Metadata/AgeRatingDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Metadata; -public class AgeRatingDto +public sealed record AgeRatingDto { public AgeRating Value { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/Browse/BrowseGenreDto.cs b/API/DTOs/Metadata/Browse/BrowseGenreDto.cs new file mode 100644 index 000000000..8044c7914 --- /dev/null +++ b/API/DTOs/Metadata/Browse/BrowseGenreDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Metadata.Browse; + +public sealed record BrowseGenreDto : GenreTagDto +{ + /// + /// Number of Series this Entity is on + /// + public int SeriesCount { get; set; } + /// + /// Number of Chapters this Entity is on + /// + public int ChapterCount { get; set; } +} diff --git a/API/DTOs/Person/BrowsePersonDto.cs b/API/DTOs/Metadata/Browse/BrowsePersonDto.cs similarity index 65% rename from API/DTOs/Person/BrowsePersonDto.cs rename to API/DTOs/Metadata/Browse/BrowsePersonDto.cs index 8d6999973..20f84b783 100644 --- a/API/DTOs/Person/BrowsePersonDto.cs +++ b/API/DTOs/Metadata/Browse/BrowsePersonDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs; +using API.DTOs.Person; + +namespace API.DTOs.Metadata.Browse; /// /// Used to browse writers and click in to see their series @@ -10,7 +12,7 @@ public class BrowsePersonDto : PersonDto /// public int SeriesCount { get; set; } /// - /// Number or Issues this Person is the Writer for + /// Number of Issues this Person is the Writer for /// - public int IssueCount { get; set; } + public int ChapterCount { get; set; } } diff --git a/API/DTOs/Metadata/Browse/BrowseTagDto.cs b/API/DTOs/Metadata/Browse/BrowseTagDto.cs new file mode 100644 index 000000000..9a71876e3 --- /dev/null +++ b/API/DTOs/Metadata/Browse/BrowseTagDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Metadata.Browse; + +public sealed record BrowseTagDto : TagDto +{ + /// + /// Number of Series this Entity is on + /// + public int SeriesCount { get; set; } + /// + /// Number of Chapters this Entity is on + /// + public int ChapterCount { get; set; } +} diff --git a/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs new file mode 100644 index 000000000..d41cf37f3 --- /dev/null +++ b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; + +namespace API.DTOs.Metadata.Browse.Requests; +#nullable enable + +public sealed record BrowsePersonFilterDto +{ + /// + /// Not used - For parity with Series Filter + /// + public int Id { get; set; } + /// + /// Not used - For parity with Series Filter + /// + public string? Name { get; set; } + public ICollection Statements { get; set; } = []; + public FilterCombination Combination { get; set; } = FilterCombination.And; + public PersonSortOptions? SortOptions { get; set; } + + /// + /// Limit the number of rows returned. Defaults to not applying a limit (aka 0) + /// + public int LimitTo { get; set; } = 0; +} diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index bbd93d618..c79436e24 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs.Metadata; @@ -9,7 +10,7 @@ namespace API.DTOs.Metadata; /// Exclusively metadata about a given chapter /// [Obsolete("Will not be maintained as of v0.8.1")] -public class ChapterMetadataDto +public sealed record ChapterMetadataDto { public int Id { get; set; } public int ChapterId { get; set; } diff --git a/API/DTOs/Metadata/GenreTagDto.cs b/API/DTOs/Metadata/GenreTagDto.cs index cf05ebbff..13a339d38 100644 --- a/API/DTOs/Metadata/GenreTagDto.cs +++ b/API/DTOs/Metadata/GenreTagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public class GenreTagDto +public record GenreTagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs new file mode 100644 index 000000000..774581b37 --- /dev/null +++ b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs @@ -0,0 +1,10 @@ +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; + +namespace API.DTOs.Metadata.Matching; + +public sealed record ExternalSeriesMatchDto +{ + public ExternalSeriesDetailDto Series { get; set; } + public float MatchRating { get; set; } +} diff --git a/API/DTOs/Metadata/Matching/MatchSeriesDto.cs b/API/DTOs/Metadata/Matching/MatchSeriesDto.cs new file mode 100644 index 000000000..bb497b9ab --- /dev/null +++ b/API/DTOs/Metadata/Matching/MatchSeriesDto.cs @@ -0,0 +1,20 @@ +namespace API.DTOs.Metadata.Matching; + +/// +/// Used for matching a series with Kavita+ for metadata and scrobbling +/// +public sealed record MatchSeriesDto +{ + /// + /// When set, Kavita will stop attempting to match this series and will not perform any scrobbling + /// + public bool DontMatch { get; set; } + /// + /// Series Id to pull internal metadata from to improve matching + /// + public int SeriesId { get; set; } + /// + /// Free form text to query for. Can be a url and ids will be parsed from it + /// + public string Query { get; set; } +} diff --git a/API/DTOs/Metadata/PublicationStatusDto.cs b/API/DTOs/Metadata/PublicationStatusDto.cs index b8166a6e5..b4f12500a 100644 --- a/API/DTOs/Metadata/PublicationStatusDto.cs +++ b/API/DTOs/Metadata/PublicationStatusDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Metadata; -public class PublicationStatusDto +public sealed record PublicationStatusDto { public PublicationStatus Value { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/TagDto.cs b/API/DTOs/Metadata/TagDto.cs index 59e03a279..f5c925e1f 100644 --- a/API/DTOs/Metadata/TagDto.cs +++ b/API/DTOs/Metadata/TagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public class TagDto +public record TagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/OPDS/Feed.cs b/API/DTOs/OPDS/Feed.cs index 76a740b89..5f4c4b115 100644 --- a/API/DTOs/OPDS/Feed.cs +++ b/API/DTOs/OPDS/Feed.cs @@ -4,11 +4,13 @@ using System.Xml.Serialization; namespace API.DTOs.OPDS; +// TODO: OPDS Dtos are internal state, shouldn't be in DTO directory + /// /// /// [XmlRoot("feed", Namespace = "http://www.w3.org/2005/Atom")] -public class Feed +public sealed record Feed { [XmlElement("updated")] public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); diff --git a/API/DTOs/OPDS/FeedAuthor.cs b/API/DTOs/OPDS/FeedAuthor.cs index 1fd3e6cd2..4196997dd 100644 --- a/API/DTOs/OPDS/FeedAuthor.cs +++ b/API/DTOs/OPDS/FeedAuthor.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class FeedAuthor +public sealed record FeedAuthor { [XmlElement("name")] public string Name { get; set; } diff --git a/API/DTOs/OPDS/FeedCategory.cs b/API/DTOs/OPDS/FeedCategory.cs index 3129fab60..2352b4af2 100644 --- a/API/DTOs/OPDS/FeedCategory.cs +++ b/API/DTOs/OPDS/FeedCategory.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class FeedCategory +public sealed record FeedCategory { [XmlAttribute("scheme")] public string Scheme { get; } = "http://www.bisg.org/standards/bisac_subject/index.html"; diff --git a/API/DTOs/OPDS/FeedEntry.cs b/API/DTOs/OPDS/FeedEntry.cs index da8b53b74..838ebd124 100644 --- a/API/DTOs/OPDS/FeedEntry.cs +++ b/API/DTOs/OPDS/FeedEntry.cs @@ -5,7 +5,7 @@ using System.Xml.Serialization; namespace API.DTOs.OPDS; #nullable enable -public class FeedEntry +public sealed record FeedEntry { [XmlElement("updated")] public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); diff --git a/API/DTOs/OPDS/FeedEntryContent.cs b/API/DTOs/OPDS/FeedEntryContent.cs index 3e95ce643..4de9b73bd 100644 --- a/API/DTOs/OPDS/FeedEntryContent.cs +++ b/API/DTOs/OPDS/FeedEntryContent.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class FeedEntryContent +public sealed record FeedEntryContent { [XmlAttribute("type")] public string Type = "text"; diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/FeedLink.cs index cff3b6736..28c55bbe8 100644 --- a/API/DTOs/OPDS/FeedLink.cs +++ b/API/DTOs/OPDS/FeedLink.cs @@ -3,7 +3,7 @@ using System.Xml.Serialization; namespace API.DTOs.OPDS; -public class FeedLink +public sealed record FeedLink { [XmlIgnore] public bool IsPageStream { get; set; } diff --git a/API/DTOs/OPDS/OpenSearchDescription.cs b/API/DTOs/OPDS/OpenSearchDescription.cs index cc8392a88..eba26572f 100644 --- a/API/DTOs/OPDS/OpenSearchDescription.cs +++ b/API/DTOs/OPDS/OpenSearchDescription.cs @@ -3,7 +3,7 @@ namespace API.DTOs.OPDS; [XmlRoot("OpenSearchDescription", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] -public class OpenSearchDescription +public sealed record OpenSearchDescription { /// /// Contains a brief human-readable title that identifies this search engine. diff --git a/API/DTOs/OPDS/SearchLink.cs b/API/DTOs/OPDS/SearchLink.cs index dba67f3bd..b4698c221 100644 --- a/API/DTOs/OPDS/SearchLink.cs +++ b/API/DTOs/OPDS/SearchLink.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class SearchLink +public sealed record SearchLink { [XmlAttribute("type")] public string Type { get; set; } = default!; diff --git a/API/DTOs/Person/PersonDto.cs b/API/DTOs/Person/PersonDto.cs index aa0f0680c..db152e3b1 100644 --- a/API/DTOs/Person/PersonDto.cs +++ b/API/DTOs/Person/PersonDto.cs @@ -1,4 +1,7 @@ -namespace API.DTOs; +using System.Collections.Generic; + +namespace API.DTOs.Person; +#nullable enable public class PersonDto { @@ -6,12 +9,13 @@ public class PersonDto public required string Name { get; set; } public bool CoverImageLocked { get; set; } - public string PrimaryColor { get; set; } - public string SecondaryColor { get; set; } + public string? PrimaryColor { get; set; } + public string? SecondaryColor { get; set; } public string? CoverImage { get; set; } + public List Aliases { get; set; } = []; - public string Description { get; set; } + public string? Description { get; set; } /// /// ASIN for person /// diff --git a/API/DTOs/Person/PersonMergeDto.cs b/API/DTOs/Person/PersonMergeDto.cs new file mode 100644 index 000000000..b5dc23375 --- /dev/null +++ b/API/DTOs/Person/PersonMergeDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs; + +public sealed record PersonMergeDto +{ + /// + /// The id of the person being merged into + /// + [Required] + public int DestId { get; init; } + /// + /// The id of the person being merged. This person will be removed, and become an alias of + /// + [Required] + public int SrcId { get; init; } +} diff --git a/API/DTOs/Person/UpdatePersonDto.cs b/API/DTOs/Person/UpdatePersonDto.cs index 78eb54aaf..b43a45e88 100644 --- a/API/DTOs/Person/UpdatePersonDto.cs +++ b/API/DTOs/Person/UpdatePersonDto.cs @@ -1,8 +1,10 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace API.DTOs; +#nullable enable -public class UpdatePersonDto +public sealed record UpdatePersonDto { [Required] public int Id { get; init; } @@ -10,6 +12,7 @@ public class UpdatePersonDto public bool CoverImageLocked { get; set; } [Required] public string Name {get; set;} + public IList Aliases { get; set; } = []; public string? Description { get; set; } public int? AniListId { get; set; } diff --git a/API/DTOs/Progress/FullProgressDto.cs b/API/DTOs/Progress/FullProgressDto.cs index 7d0b47f60..4f97ab44a 100644 --- a/API/DTOs/Progress/FullProgressDto.cs +++ b/API/DTOs/Progress/FullProgressDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Progress; /// /// A full progress Record from the DB (not all data, only what's needed for API) /// -public class FullProgressDto +public sealed record FullProgressDto { public int Id { get; set; } public int ChapterId { get; set; } diff --git a/API/DTOs/Progress/ProgressDto.cs b/API/DTOs/Progress/ProgressDto.cs index 9fc9010aa..0add848c5 100644 --- a/API/DTOs/Progress/ProgressDto.cs +++ b/API/DTOs/Progress/ProgressDto.cs @@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Progress; #nullable enable -public class ProgressDto +public sealed record ProgressDto { [Required] public int VolumeId { get; set; } diff --git a/API/DTOs/Progress/UpdateUserProgressDto.cs b/API/DTOs/Progress/UpdateUserProgressDto.cs deleted file mode 100644 index 2aa77b04e..000000000 --- a/API/DTOs/Progress/UpdateUserProgressDto.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace API.DTOs.Progress; -#nullable enable - -public class UpdateUserProgressDto -{ - public int PageNum { get; set; } - public DateTime LastModifiedUtc { get; set; } - public DateTime CreatedUtc { get; set; } -} diff --git a/API/DTOs/RatingDto.cs b/API/DTOs/RatingDto.cs index e2cd9d342..101aa7ac5 100644 --- a/API/DTOs/RatingDto.cs +++ b/API/DTOs/RatingDto.cs @@ -1,12 +1,18 @@ -using API.Services.Plus; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Services.Plus; namespace API.DTOs; #nullable enable -public class RatingDto +public sealed record RatingDto { + public int AverageScore { get; set; } public int FavoriteCount { get; set; } public ScrobbleProvider Provider { get; set; } + /// + public RatingAuthority Authority { get; set; } = RatingAuthority.User; public string? ProviderUrl { get; set; } } diff --git a/API/DTOs/Reader/BookChapterItem.cs b/API/DTOs/Reader/BookChapterItem.cs index dcfb7b904..892e82e27 100644 --- a/API/DTOs/Reader/BookChapterItem.cs +++ b/API/DTOs/Reader/BookChapterItem.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class BookChapterItem +public sealed record BookChapterItem { /// /// Name of the Chapter diff --git a/API/DTOs/Reader/BookInfoDto.cs b/API/DTOs/Reader/BookInfoDto.cs index c379f71f8..2473cd5dc 100644 --- a/API/DTOs/Reader/BookInfoDto.cs +++ b/API/DTOs/Reader/BookInfoDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class BookInfoDto : IChapterInfoDto +public sealed record BookInfoDto : IChapterInfoDto { public string BookTitle { get; set; } = default! ; public int SeriesId { get; set; } diff --git a/API/DTOs/Reader/BookmarkDto.cs b/API/DTOs/Reader/BookmarkDto.cs index ef4cf3d6d..da18fc28e 100644 --- a/API/DTOs/Reader/BookmarkDto.cs +++ b/API/DTOs/Reader/BookmarkDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Reader; #nullable enable -public class BookmarkDto +public sealed record BookmarkDto { public int Id { get; set; } [Required] diff --git a/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs index 7490f837c..51ccf5cc3 100644 --- a/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs +++ b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class BulkRemoveBookmarkForSeriesDto +public sealed record BulkRemoveBookmarkForSeriesDto { public ICollection SeriesIds { get; init; } = default!; } diff --git a/API/DTOs/Reader/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs index 4584a5830..4da08a31d 100644 --- a/API/DTOs/Reader/ChapterInfoDto.cs +++ b/API/DTOs/Reader/ChapterInfoDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.Reader; /// /// Information about the Chapter for the Reader to render /// -public class ChapterInfoDto : IChapterInfoDto +public sealed record ChapterInfoDto : IChapterInfoDto { /// /// The Chapter Number diff --git a/API/DTOs/Reader/CreatePersonalToCDto.cs b/API/DTOs/Reader/CreatePersonalToCDto.cs index 25526b490..95272ca58 100644 --- a/API/DTOs/Reader/CreatePersonalToCDto.cs +++ b/API/DTOs/Reader/CreatePersonalToCDto.cs @@ -1,6 +1,7 @@ namespace API.DTOs.Reader; +#nullable enable -public class CreatePersonalToCDto +public sealed record CreatePersonalToCDto { public required int ChapterId { get; set; } public required int VolumeId { get; set; } diff --git a/API/DTOs/Reader/FileDimensionDto.cs b/API/DTOs/Reader/FileDimensionDto.cs index baee20dd0..7a7d2978f 100644 --- a/API/DTOs/Reader/FileDimensionDto.cs +++ b/API/DTOs/Reader/FileDimensionDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class FileDimensionDto +public sealed record FileDimensionDto { public int Width { get; set; } public int Height { get; set; } diff --git a/API/DTOs/Reader/HourEstimateRangeDto.cs b/API/DTOs/Reader/HourEstimateRangeDto.cs index 8c8bd11a9..3facf8e56 100644 --- a/API/DTOs/Reader/HourEstimateRangeDto.cs +++ b/API/DTOs/Reader/HourEstimateRangeDto.cs @@ -3,7 +3,7 @@ /// /// A range of time to read a selection (series, chapter, etc) /// -public record HourEstimateRangeDto +public sealed record HourEstimateRangeDto { /// /// Min hours to read the selection diff --git a/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs index 50187ec81..4c39f7d76 100644 --- a/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs +++ b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class MarkMultipleSeriesAsReadDto +public sealed record MarkMultipleSeriesAsReadDto { public IReadOnlyList SeriesIds { get; init; } = default!; } diff --git a/API/DTOs/Reader/MarkReadDto.cs b/API/DTOs/Reader/MarkReadDto.cs index 9bf46a6d5..c6f7367c0 100644 --- a/API/DTOs/Reader/MarkReadDto.cs +++ b/API/DTOs/Reader/MarkReadDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class MarkReadDto +public sealed record MarkReadDto { public int SeriesId { get; init; } } diff --git a/API/DTOs/Reader/MarkVolumeReadDto.cs b/API/DTOs/Reader/MarkVolumeReadDto.cs index 47ffd2649..be95d2e98 100644 --- a/API/DTOs/Reader/MarkVolumeReadDto.cs +++ b/API/DTOs/Reader/MarkVolumeReadDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class MarkVolumeReadDto +public sealed record MarkVolumeReadDto { public int SeriesId { get; init; } public int VolumeId { get; init; } diff --git a/API/DTOs/Reader/MarkVolumesReadDto.cs b/API/DTOs/Reader/MarkVolumesReadDto.cs index ebe1cd76c..b07bfbc67 100644 --- a/API/DTOs/Reader/MarkVolumesReadDto.cs +++ b/API/DTOs/Reader/MarkVolumesReadDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Reader; /// /// This is used for bulk updating a set of volume and or chapters in one go /// -public class MarkVolumesReadDto +public sealed record MarkVolumesReadDto { public int SeriesId { get; set; } /// diff --git a/API/DTOs/Reader/PersonalToCDto.cs b/API/DTOs/Reader/PersonalToCDto.cs index 144ed561f..c979d9d78 100644 --- a/API/DTOs/Reader/PersonalToCDto.cs +++ b/API/DTOs/Reader/PersonalToCDto.cs @@ -2,7 +2,7 @@ #nullable enable -public class PersonalToCDto +public sealed record PersonalToCDto { public required int ChapterId { get; set; } public required int PageNumber { get; set; } diff --git a/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs index ed6368a4f..ecbb744c8 100644 --- a/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs +++ b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class RemoveBookmarkForSeriesDto +public sealed record RemoveBookmarkForSeriesDto { public int SeriesId { get; init; } } diff --git a/API/DTOs/ReadingLists/CBL/CblBook.cs b/API/DTOs/ReadingLists/CBL/CblBook.cs index 08930e208..d51795b8d 100644 --- a/API/DTOs/ReadingLists/CBL/CblBook.cs +++ b/API/DTOs/ReadingLists/CBL/CblBook.cs @@ -5,7 +5,7 @@ namespace API.DTOs.ReadingLists.CBL; [XmlRoot(ElementName="Book")] -public class CblBook +public sealed record CblBook { [XmlAttribute("Series")] public string Series { get; set; } diff --git a/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs b/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs index 70a002884..35234923f 100644 --- a/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs +++ b/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.ReadingLists.CBL; -public class CblConflictQuestion +public sealed record CblConflictQuestion { public string SeriesName { get; set; } public IList LibrariesIds { get; set; } diff --git a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs index 136a31aa8..b9716421e 100644 --- a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs +++ b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs @@ -75,7 +75,7 @@ public enum CblImportReason InvalidFile = 9, } -public class CblBookResult +public sealed record CblBookResult { /// /// Order in the CBL @@ -114,7 +114,7 @@ public class CblBookResult /// /// Represents the summary from the Import of a given CBL /// -public class CblImportSummaryDto +public sealed record CblImportSummaryDto { public string CblName { get; set; } /// diff --git a/API/DTOs/ReadingLists/CBL/CblReadingList.cs b/API/DTOs/ReadingLists/CBL/CblReadingList.cs index 001e6434b..15b349f42 100644 --- a/API/DTOs/ReadingLists/CBL/CblReadingList.cs +++ b/API/DTOs/ReadingLists/CBL/CblReadingList.cs @@ -5,7 +5,7 @@ namespace API.DTOs.ReadingLists.CBL; [XmlRoot(ElementName="Books")] -public class CblBooks +public sealed record CblBooks { [XmlElement(ElementName="Book")] public List Book { get; set; } @@ -13,7 +13,7 @@ public class CblBooks [XmlRoot(ElementName="ReadingList")] -public class CblReadingList +public sealed record CblReadingList { /// /// Name of the Reading List diff --git a/API/DTOs/ReadingLists/CreateReadingListDto.cs b/API/DTOs/ReadingLists/CreateReadingListDto.cs index 783253007..543215722 100644 --- a/API/DTOs/ReadingLists/CreateReadingListDto.cs +++ b/API/DTOs/ReadingLists/CreateReadingListDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class CreateReadingListDto +public sealed record CreateReadingListDto { public string Title { get; init; } = default!; } diff --git a/API/DTOs/ReadingLists/DeleteReadingListsDto.cs b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs index 8417f8132..8ce92f939 100644 --- a/API/DTOs/ReadingLists/DeleteReadingListsDto.cs +++ b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.ReadingLists; -public class DeleteReadingListsDto +public sealed record DeleteReadingListsDto { [Required] public IList ReadingListIds { get; set; } diff --git a/API/DTOs/ReadingLists/PromoteReadingListsDto.cs b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs index f64bbb5ca..8915274de 100644 --- a/API/DTOs/ReadingLists/PromoteReadingListsDto.cs +++ b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class PromoteReadingListsDto +public sealed record PromoteReadingListsDto { public IList ReadingListIds { get; init; } public bool Promoted { get; init; } diff --git a/API/DTOs/ReadingLists/ReadingListCast.cs b/API/DTOs/ReadingLists/ReadingListCast.cs new file mode 100644 index 000000000..855bb12b7 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListCast.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using API.DTOs.Person; + +namespace API.DTOs.ReadingLists; + +public sealed record ReadingListCast +{ + public ICollection Writers { get; set; } = []; + public ICollection CoverArtists { get; set; } = []; + public ICollection Publishers { get; set; } = []; + public ICollection Characters { get; set; } = []; + public ICollection Pencillers { get; set; } = []; + public ICollection Inkers { get; set; } = []; + public ICollection Imprints { get; set; } = []; + public ICollection Colorists { get; set; } = []; + public ICollection Letterers { get; set; } = []; + public ICollection Editors { get; set; } = []; + public ICollection Translators { get; set; } = []; + public ICollection Teams { get; set; } = []; + public ICollection Locations { get; set; } = []; +} diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index 139039bf5..47a526411 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -1,10 +1,11 @@ using System; +using API.Entities.Enums; using API.Entities.Interfaces; namespace API.DTOs.ReadingLists; #nullable enable -public class ReadingListDto : IHasCoverImage +public sealed record ReadingListDto : IHasCoverImage { public int Id { get; init; } public string Title { get; set; } = default!; @@ -19,8 +20,8 @@ public class ReadingListDto : IHasCoverImage /// public string? CoverImage { get; set; } = string.Empty; - public string PrimaryColor { get; set; } = string.Empty; - public string SecondaryColor { get; set; } = string.Empty; + public string? PrimaryColor { get; set; } = string.Empty; + public string? SecondaryColor { get; set; } = string.Empty; /// /// Number of Items in the Reading List @@ -43,6 +44,15 @@ public class ReadingListDto : IHasCoverImage /// Maximum Month the Reading List starts /// public int EndingMonth { get; set; } + /// + /// The highest age rating from all Series within the reading list + /// + public required AgeRating AgeRating { get; set; } = AgeRating.Unknown; + + /// + /// Username of the User that owns (in the case of a promoted list) + /// + public string OwnerUserName { get; set; } public void ResetColorScape() { diff --git a/API/DTOs/ReadingLists/ReadingListInfoDto.cs b/API/DTOs/ReadingLists/ReadingListInfoDto.cs new file mode 100644 index 000000000..64a305f43 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListInfoDto.cs @@ -0,0 +1,26 @@ +using API.DTOs.Reader; +using API.Entities.Interfaces; + +namespace API.DTOs.ReadingLists; + +public sealed record ReadingListInfoDto : IHasReadTimeEstimate +{ + /// + /// Total Pages across all Reading List Items + /// + public int Pages { get; set; } + /// + /// Total Word count across all Reading List Items + /// + public long WordCount { get; set; } + /// + /// Are ALL Reading List Items epub + /// + public bool IsAllEpub { get; set; } + /// + public int MinHoursToRead { get; set; } + /// + public int MaxHoursToRead { get; set; } + /// + public float AvgHoursToRead { get; set; } +} diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index f1238d333..8edec14f1 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.ReadingLists; #nullable enable -public class ReadingListItemDto +public sealed record ReadingListItemDto { public int Id { get; init; } public int Order { get; init; } @@ -25,7 +25,7 @@ public class ReadingListItemDto /// /// Release Date from Chapter /// - public DateTime ReleaseDate { get; set; } + public DateTime? ReleaseDate { get; set; } /// /// Used internally only /// @@ -33,7 +33,7 @@ public class ReadingListItemDto /// /// The last time a reading list item (underlying chapter) was read by current authenticated user /// - public DateTime LastReadingProgressUtc { get; set; } + public DateTime? LastReadingProgressUtc { get; set; } /// /// File size of underlying item /// diff --git a/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs index 985f86ac0..6624c8a5c 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByChapterDto +public sealed record UpdateReadingListByChapterDto { public int ChapterId { get; init; } public int SeriesId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs index 408963529..ba7625088 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByMultipleDto +public sealed record UpdateReadingListByMultipleDto { public int SeriesId { get; init; } public int ReadingListId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs index f910e9c06..910a5744d 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByMultipleSeriesDto +public sealed record UpdateReadingListByMultipleSeriesDto { public int ReadingListId { get; init; } public IReadOnlyList SeriesIds { get; init; } = default!; diff --git a/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs index 0590882bd..4bb4aa7bb 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListBySeriesDto +public sealed record UpdateReadingListBySeriesDto { public int SeriesId { get; init; } public int ReadingListId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs index f77c7d63a..422d1cc34 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByVolumeDto +public sealed record UpdateReadingListByVolumeDto { public int VolumeId { get; init; } public int SeriesId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListDto.cs b/API/DTOs/ReadingLists/UpdateReadingListDto.cs index 6b590707a..de273d825 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListDto +public sealed record UpdateReadingListDto { [Required] public int ReadingListId { get; set; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs index 3d0487144..04f2501a8 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs @@ -5,7 +5,7 @@ namespace API.DTOs.ReadingLists; /// /// DTO for moving a reading list item to another position within the same list /// -public class UpdateReadingListPosition +public sealed record UpdateReadingListPosition { [Required] public int ReadingListId { get; set; } [Required] public int ReadingListItemId { get; set; } diff --git a/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs b/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs deleted file mode 100644 index 9aa852fd7..000000000 --- a/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using API.DTOs.Scrobbling; -using API.Services.Plus; - -namespace API.DTOs.Recommendation; -#nullable enable - -public class ExternalSeriesDetailDto -{ - public string Name { get; set; } - public int? AniListId { get; set; } - public long? MALId { get; set; } - public IList Synonyms { get; set; } - public MediaFormat PlusMediaFormat { get; set; } - public string? SiteUrl { get; set; } - public string? CoverUrl { get; set; } - public IList Genres { get; set; } - public IList Staff { get; set; } - public IList Tags { get; set; } - public string? Summary { get; set; } - public int? VolumeCount { get; set; } - public int? ChapterCount { get; set; } - public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; -} diff --git a/API/DTOs/Recommendation/ExternalSeriesDto.cs b/API/DTOs/Recommendation/ExternalSeriesDto.cs index 55d2d320c..752001a39 100644 --- a/API/DTOs/Recommendation/ExternalSeriesDto.cs +++ b/API/DTOs/Recommendation/ExternalSeriesDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Recommendation; #nullable enable -public class ExternalSeriesDto +public sealed record ExternalSeriesDto { public required string Name { get; set; } public required string CoverUrl { get; set; } @@ -12,4 +12,6 @@ public class ExternalSeriesDto public int? AniListId { get; set; } public long? MalId { get; set; } public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; + + } diff --git a/API/DTOs/Recommendation/MetadataTagDto.cs b/API/DTOs/Recommendation/MetadataTagDto.cs index b219dedc1..a7eb76284 100644 --- a/API/DTOs/Recommendation/MetadataTagDto.cs +++ b/API/DTOs/Recommendation/MetadataTagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Recommendation; -public class MetadataTagDto +public sealed record MetadataTagDto { public string Name { get; set; } public string Description { get; private set; } diff --git a/API/DTOs/Recommendation/RecommendationDto.cs b/API/DTOs/Recommendation/RecommendationDto.cs index 679245a87..387661324 100644 --- a/API/DTOs/Recommendation/RecommendationDto.cs +++ b/API/DTOs/Recommendation/RecommendationDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Recommendation; -public class RecommendationDto +public sealed record RecommendationDto { public IList OwnedSeries { get; set; } = new List(); public IList ExternalSeries { get; set; } = new List(); diff --git a/API/DTOs/Recommendation/SeriesStaffDto.cs b/API/DTOs/Recommendation/SeriesStaffDto.cs index 0c1e9759d..e074e8625 100644 --- a/API/DTOs/Recommendation/SeriesStaffDto.cs +++ b/API/DTOs/Recommendation/SeriesStaffDto.cs @@ -1,9 +1,11 @@ namespace API.DTOs.Recommendation; #nullable enable -public class SeriesStaffDto +public sealed record SeriesStaffDto { public required string Name { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } public required string Url { get; set; } public required string Role { get; set; } public string? ImageUrl { get; set; } diff --git a/API/DTOs/RefreshSeriesDto.cs b/API/DTOs/RefreshSeriesDto.cs index 0e94fc44b..ad26afba2 100644 --- a/API/DTOs/RefreshSeriesDto.cs +++ b/API/DTOs/RefreshSeriesDto.cs @@ -3,7 +3,7 @@ /// /// Used for running some task against a Series. /// -public class RefreshSeriesDto +public sealed record RefreshSeriesDto { /// /// Library Id series belongs to diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index d0118e385..e117af872 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -1,15 +1,16 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs; +#nullable enable -public class RegisterDto +public sealed record RegisterDto { [Required] public string Username { get; init; } = default!; /// /// An email to register with. Optional. Provides Forgot Password functionality /// - public string Email { get; init; } = default!; + public string? Email { get; set; } = default!; [Required] [StringLength(256, MinimumLength = 6)] public string Password { get; set; } = default!; diff --git a/API/DTOs/ScanFolderDto.cs b/API/DTOs/ScanFolderDto.cs index 684de909e..141f7f0b5 100644 --- a/API/DTOs/ScanFolderDto.cs +++ b/API/DTOs/ScanFolderDto.cs @@ -3,7 +3,7 @@ /// /// DTO for requesting a folder to be scanned /// -public class ScanFolderDto +public sealed record ScanFolderDto { /// /// Api key for a user with Admin permissions diff --git a/API/DTOs/Scrobbling/MalUserInfoDto.cs b/API/DTOs/Scrobbling/MalUserInfoDto.cs index 407639e2a..b6fefc053 100644 --- a/API/DTOs/Scrobbling/MalUserInfoDto.cs +++ b/API/DTOs/Scrobbling/MalUserInfoDto.cs @@ -3,7 +3,7 @@ /// /// Information about a User's MAL connection /// -public class MalUserInfoDto +public sealed record MalUserInfoDto { public required string Username { get; set; } /// diff --git a/API/DTOs/Scrobbling/MediaRecommendationDto.cs b/API/DTOs/Scrobbling/MediaRecommendationDto.cs index c83694b2b..476d77279 100644 --- a/API/DTOs/Scrobbling/MediaRecommendationDto.cs +++ b/API/DTOs/Scrobbling/MediaRecommendationDto.cs @@ -2,8 +2,9 @@ using API.Services.Plus; namespace API.DTOs.Scrobbling; +#nullable enable -public record MediaRecommendationDto +public sealed record MediaRecommendationDto { public int Rating { get; set; } public IEnumerable RecommendationNames { get; set; } = null!; diff --git a/API/DTOs/Scrobbling/PlusSeriesDto.cs b/API/DTOs/Scrobbling/PlusSeriesDto.cs index 552a86575..4d0ef4ea1 100644 --- a/API/DTOs/Scrobbling/PlusSeriesDto.cs +++ b/API/DTOs/Scrobbling/PlusSeriesDto.cs @@ -1,14 +1,22 @@ namespace API.DTOs.Scrobbling; +#nullable enable -public record PlusSeriesDto +/// +/// Represents information about a potential Series for Kavita+ +/// +public sealed record PlusSeriesRequestDto { public int? AniListId { get; set; } public long? MalId { get; set; } public string? GoogleBooksId { get; set; } public string? MangaDexId { get; set; } + /// + /// ComicBookRoundup Id + /// + public int? CbrId { get; set; } public string SeriesName { get; set; } public string? AltSeriesName { get; set; } - public MediaFormat MediaFormat { get; set; } + public PlusMediaFormat MediaFormat { get; set; } /// /// Optional but can help with matching /// diff --git a/API/DTOs/Scrobbling/ScrobbleDto.cs b/API/DTOs/Scrobbling/ScrobbleDto.cs index ca2c2e528..b90441059 100644 --- a/API/DTOs/Scrobbling/ScrobbleDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleDto.cs @@ -22,7 +22,7 @@ public enum ScrobbleEventType /// /// Represents PlusMediaFormat /// -public enum MediaFormat +public enum PlusMediaFormat { [Description("Manga")] Manga = 1, @@ -36,7 +36,7 @@ public enum MediaFormat } -public class ScrobbleDto +public sealed record ScrobbleDto { /// /// User's access token to allow us to talk on their behalf @@ -44,7 +44,7 @@ public class ScrobbleDto public string AniListToken { get; set; } public string SeriesName { get; set; } public string LocalizedSeriesName { get; set; } - public MediaFormat Format { get; set; } + public PlusMediaFormat Format { get; set; } public int? Year { get; set; } /// /// Optional AniListId if present on Kavita's WebLinks diff --git a/API/DTOs/Scrobbling/ScrobbleErrorDto.cs b/API/DTOs/Scrobbling/ScrobbleErrorDto.cs index da85f28f1..7caaad1ca 100644 --- a/API/DTOs/Scrobbling/ScrobbleErrorDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleErrorDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Scrobbling; -public class ScrobbleErrorDto +public sealed record ScrobbleErrorDto { /// /// Developer defined string diff --git a/API/DTOs/Scrobbling/ScrobbleEventDto.cs b/API/DTOs/Scrobbling/ScrobbleEventDto.cs index 298e32180..562d923ff 100644 --- a/API/DTOs/Scrobbling/ScrobbleEventDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleEventDto.cs @@ -1,9 +1,11 @@ using System; namespace API.DTOs.Scrobbling; +#nullable enable -public class ScrobbleEventDto +public sealed record ScrobbleEventDto { + public long Id { get; init; } public string SeriesName { get; set; } public int SeriesId { get; set; } public int LibraryId { get; set; } diff --git a/API/DTOs/Scrobbling/ScrobbleHoldDto.cs b/API/DTOs/Scrobbling/ScrobbleHoldDto.cs index dcfe7726f..3e09e4799 100644 --- a/API/DTOs/Scrobbling/ScrobbleHoldDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleHoldDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Scrobbling; -public class ScrobbleHoldDto +public sealed record ScrobbleHoldDto { public string SeriesName { get; set; } public int SeriesId { get; set; } diff --git a/API/DTOs/Scrobbling/ScrobbleResponseDto.cs b/API/DTOs/Scrobbling/ScrobbleResponseDto.cs index a63e955d7..ad66729d0 100644 --- a/API/DTOs/Scrobbling/ScrobbleResponseDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleResponseDto.cs @@ -4,9 +4,10 @@ /// /// Response from Kavita+ Scrobble API /// -public class ScrobbleResponseDto +public sealed record ScrobbleResponseDto { public bool Successful { get; set; } public string? ErrorMessage { get; set; } + public string? ExtraInformation {get; set;} public int RateLeft { get; set; } } diff --git a/API/DTOs/Search/BookmarkSearchResultDto.cs b/API/DTOs/Search/BookmarkSearchResultDto.cs index 5d53add1f..c11d2a2b8 100644 --- a/API/DTOs/Search/BookmarkSearchResultDto.cs +++ b/API/DTOs/Search/BookmarkSearchResultDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Search; -public class BookmarkSearchResultDto +public sealed record BookmarkSearchResultDto { public int LibraryId { get; set; } public int VolumeId { get; set; } diff --git a/API/DTOs/Search/SearchResultDto.cs b/API/DTOs/Search/SearchResultDto.cs index 6fcae3b5d..c497b55dd 100644 --- a/API/DTOs/Search/SearchResultDto.cs +++ b/API/DTOs/Search/SearchResultDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Search; -public class SearchResultDto +public sealed record SearchResultDto { public int SeriesId { get; init; } public string Name { get; init; } = default!; diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index f7a622664..11c4bdc08 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -2,6 +2,7 @@ using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Reader; using API.DTOs.ReadingLists; @@ -10,7 +11,7 @@ namespace API.DTOs.Search; /// /// Represents all Search results for a query /// -public class SearchResultGroupDto +public sealed record SearchResultGroupDto { public IEnumerable Libraries { get; set; } = default!; public IEnumerable Series { get; set; } = default!; diff --git a/API/DTOs/SeriesByIdsDto.cs b/API/DTOs/SeriesByIdsDto.cs index 12e13d96f..cb4c52b1e 100644 --- a/API/DTOs/SeriesByIdsDto.cs +++ b/API/DTOs/SeriesByIdsDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs; -public class SeriesByIdsDto +public sealed record SeriesByIdsDto { public int[] SeriesIds { get; init; } = default!; } diff --git a/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs index 0f1a8eb4b..1bea81c84 100644 --- a/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs +++ b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; -public class NextExpectedChapterDto +public sealed record NextExpectedChapterDto { public float ChapterNumber { get; set; } public float VolumeNumber { get; set; } diff --git a/API/DTOs/SeriesDetail/RelatedSeriesDto.cs b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs index 29b9eb263..a186dc295 100644 --- a/API/DTOs/SeriesDetail/RelatedSeriesDto.cs +++ b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; -public class RelatedSeriesDto +public sealed record RelatedSeriesDto { /// /// The parent relationship Series diff --git a/API/DTOs/SeriesDetail/SeriesDetailDto.cs b/API/DTOs/SeriesDetail/SeriesDetailDto.cs index 65d657c67..c4f15552d 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.SeriesDetail; /// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout. /// This is subject to change, do not rely on this Data model. /// -public class SeriesDetailDto +public sealed record SeriesDetailDto { /// /// Specials for the Series. These will have their title and range cleaned to remove the special marker and prepare diff --git a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs index 59ce47bf6..95f5f39bd 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs @@ -1,15 +1,18 @@ using System.Collections.Generic; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Recommendation; namespace API.DTOs.SeriesDetail; +#nullable enable /// /// All the data from Kavita+ for Series Detail /// /// This is what the UI sees, not what the API sends back -public class SeriesDetailPlusDto +public sealed record SeriesDetailPlusDto { public RecommendationDto? Recommendations { get; set; } public IEnumerable Reviews { get; set; } public IEnumerable? Ratings { get; set; } + public ExternalSeriesDetailDto? Series { get; set; } } diff --git a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs index f19ad9ca8..a1bb2057e 100644 --- a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs +++ b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; -public class UpdateRelatedSeriesDto +public sealed record UpdateRelatedSeriesDto { public int SeriesId { get; set; } public IList Adaptations { get; set; } = default!; diff --git a/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs b/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs index b25b01672..7af9441c1 100644 --- a/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs +++ b/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs @@ -1,10 +1,10 @@ -using System.ComponentModel.DataAnnotations; - + namespace API.DTOs.SeriesDetail; #nullable enable -public class UpdateUserReviewDto +public sealed record UpdateUserReviewDto { public int SeriesId { get; set; } + public int? ChapterId { get; set; } public string Body { get; set; } } diff --git a/API/DTOs/SeriesDetail/UserReviewDto.cs b/API/DTOs/SeriesDetail/UserReviewDto.cs index 0e080d43f..9e05bbd65 100644 --- a/API/DTOs/SeriesDetail/UserReviewDto.cs +++ b/API/DTOs/SeriesDetail/UserReviewDto.cs @@ -1,4 +1,6 @@ -using API.Services.Plus; +using API.Entities; +using API.Entities.Enums; +using API.Services.Plus; namespace API.DTOs.SeriesDetail; #nullable enable @@ -7,7 +9,7 @@ namespace API.DTOs.SeriesDetail; /// Represents a User Review for a given Series /// /// The user does not need to be a Kavita user -public class UserReviewDto +public sealed record UserReviewDto { /// /// A tagline for the review @@ -26,6 +28,7 @@ public class UserReviewDto /// The series this is for /// public int SeriesId { get; set; } + public int? ChapterId { get; set; } /// /// The library this series belongs in /// @@ -54,4 +57,8 @@ public class UserReviewDto /// If this review is External, which Provider did it come from /// public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita; + /// + /// Source of the Rating + /// + public RatingAuthority Authority { get; set; } = RatingAuthority.User; } diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index 445044eef..8a49d4c05 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -5,14 +5,21 @@ using API.Entities.Interfaces; namespace API.DTOs; #nullable enable -public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage +public sealed record SeriesDto : IHasReadTimeEstimate, IHasCoverImage { + /// public int Id { get; init; } + /// public string? Name { get; init; } + /// public string? OriginalName { get; init; } + /// public string? LocalizedName { get; init; } + /// public string? SortName { get; init; } + /// public int Pages { get; init; } + /// public bool CoverImageLocked { get; set; } /// /// Sum of pages read from linked Volumes. Calculated at API-time. @@ -22,9 +29,7 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage /// DateTime representing last time the series was Read. Calculated at API-time. /// public DateTime LatestReadDate { get; set; } - /// - /// DateTime representing last time a chapter was added to the Series - /// + /// public DateTime LastChapterAdded { get; set; } /// /// Rating from logged in user. Calculated at API-time. @@ -35,17 +40,19 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage /// public bool HasUserRated { get; set; } + /// public MangaFormat Format { get; set; } + /// public DateTime Created { get; set; } - public bool NameLocked { get; set; } + /// public bool SortNameLocked { get; set; } + /// public bool LocalizedNameLocked { get; set; } - /// - /// Total number of words for the series. Only applies to epubs. - /// + /// public long WordCount { get; set; } + /// public int LibraryId { get; set; } public string LibraryName { get; set; } = default!; /// @@ -54,23 +61,25 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage public int MaxHoursToRead { get; set; } /// public float AvgHoursToRead { get; set; } - /// - /// The highest level folder for this Series - /// + /// public string FolderPath { get; set; } = default!; - /// - /// Lowest path (that is under library root) that contains all files for the series. - /// - /// must be used before setting + /// public string? LowestFolderPath { get; set; } - /// - /// The last time the folder for this series was scanned - /// + /// public DateTime LastFolderScanned { get; set; } + #region KavitaPlus + /// + public bool DontMatch { get; set; } + /// + public bool IsBlacklisted { get; set; } + #endregion + /// public string? CoverImage { get; set; } - public string PrimaryColor { get; set; } - public string SecondaryColor { get; set; } + /// + public string? PrimaryColor { get; set; } = string.Empty; + /// + public string? SecondaryColor { get; set; } = string.Empty; public void ResetColorScape() { diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index 3f344dff5..fa745148e 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs; -public class SeriesMetadataDto +public sealed record SeriesMetadataDto { public int Id { get; set; } public string Summary { get; set; } = string.Empty; diff --git a/API/DTOs/Settings/SMTPConfigDto.cs b/API/DTOs/Settings/SMTPConfigDto.cs index 07cc58cb8..c14140062 100644 --- a/API/DTOs/Settings/SMTPConfigDto.cs +++ b/API/DTOs/Settings/SMTPConfigDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Settings; -public class SmtpConfigDto +public sealed record SmtpConfigDto { public string SenderAddress { get; set; } = string.Empty; public string SenderDisplayName { get; set; } = string.Empty; diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 45abcc528..372042250 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,10 +1,12 @@ using System; +using System.Text.Json.Serialization; using API.Entities.Enums; using API.Services; namespace API.DTOs.Settings; +#nullable enable -public class ServerSettingDto +public sealed record ServerSettingDto { public string CacheDirectory { get; set; } = default!; @@ -44,6 +46,7 @@ public class ServerSettingDto /// /// Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs. /// + public string InstallId { get; set; } = default!; /// /// The format that should be used when saving media for Kavita diff --git a/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs b/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs index 1b081913d..ae1d927a9 100644 --- a/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs +++ b/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SideNav; -public class BulkUpdateSideNavStreamVisibilityDto +public sealed record BulkUpdateSideNavStreamVisibilityDto { public required IList Ids { get; set; } public required bool Visibility { get; set; } diff --git a/API/DTOs/SideNav/ExternalSourceDto.cs b/API/DTOs/SideNav/ExternalSourceDto.cs index e9ae03066..382124e8a 100644 --- a/API/DTOs/SideNav/ExternalSourceDto.cs +++ b/API/DTOs/SideNav/ExternalSourceDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SideNav; -public class ExternalSourceDto +public sealed record ExternalSourceDto { public required int Id { get; set; } = 0; public required string Name { get; set; } diff --git a/API/DTOs/SideNav/SideNavStreamDto.cs b/API/DTOs/SideNav/SideNavStreamDto.cs index 1f3453611..f4c196244 100644 --- a/API/DTOs/SideNav/SideNavStreamDto.cs +++ b/API/DTOs/SideNav/SideNavStreamDto.cs @@ -2,8 +2,9 @@ using API.Entities.Enums; namespace API.DTOs.SideNav; +#nullable enable -public class SideNavStreamDto +public sealed record SideNavStreamDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/API/DTOs/StandaloneChapterDto.cs b/API/DTOs/StandaloneChapterDto.cs index 6d8b5423d..2f4cd2ee1 100644 --- a/API/DTOs/StandaloneChapterDto.cs +++ b/API/DTOs/StandaloneChapterDto.cs @@ -1,6 +1,7 @@ using API.Entities.Enums; namespace API.DTOs; +#nullable enable /// /// Used on Person Profile page diff --git a/API/DTOs/Statistics/Count.cs b/API/DTOs/Statistics/Count.cs index 411b44897..1577e682c 100644 --- a/API/DTOs/Statistics/Count.cs +++ b/API/DTOs/Statistics/Count.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Statistics; -public class StatCount : ICount +public sealed record StatCount : ICount { public T Value { get; set; } = default!; public long Count { get; set; } diff --git a/API/DTOs/Statistics/FileExtensionBreakdownDto.cs b/API/DTOs/Statistics/FileExtensionBreakdownDto.cs index 1f122d992..7a248caef 100644 --- a/API/DTOs/Statistics/FileExtensionBreakdownDto.cs +++ b/API/DTOs/Statistics/FileExtensionBreakdownDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.Statistics; #nullable enable -public class FileExtensionDto +public sealed record FileExtensionDto { public string? Extension { get; set; } public MangaFormat Format { get; set; } @@ -12,7 +12,7 @@ public class FileExtensionDto public long TotalFiles { get; set; } } -public class FileExtensionBreakdownDto +public sealed record FileExtensionBreakdownDto { /// /// Total bytes for all files diff --git a/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs b/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs deleted file mode 100644 index 9ce44b6fa..000000000 --- a/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace API.DTOs.Statistics; - -public class KavitaPlusMetadataBreakdownDto -{ - /// - /// Total amount of Series - /// - public int TotalSeries { get; set; } - /// - /// Series on the Blacklist (errored or bad match) - /// - public int ErroredSeries { get; set; } - /// - /// Completed so far - /// - public int SeriesCompleted { get; set; } -} diff --git a/API/DTOs/Statistics/PagesReadOnADayCount.cs b/API/DTOs/Statistics/PagesReadOnADayCount.cs index b1a6bb1ea..fc56d9cc0 100644 --- a/API/DTOs/Statistics/PagesReadOnADayCount.cs +++ b/API/DTOs/Statistics/PagesReadOnADayCount.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Statistics; -public class PagesReadOnADayCount : ICount +public sealed record PagesReadOnADayCount : ICount { /// /// The day of the readings diff --git a/API/DTOs/Statistics/ReadHistoryEvent.cs b/API/DTOs/Statistics/ReadHistoryEvent.cs index adb4040ed..5d8262aef 100644 --- a/API/DTOs/Statistics/ReadHistoryEvent.cs +++ b/API/DTOs/Statistics/ReadHistoryEvent.cs @@ -1,11 +1,12 @@ using System; namespace API.DTOs.Statistics; +#nullable enable /// /// Represents a single User's reading event /// -public class ReadHistoryEvent +public sealed record ReadHistoryEvent { public int UserId { get; set; } public required string? UserName { get; set; } = default!; @@ -13,6 +14,7 @@ public class ReadHistoryEvent public int SeriesId { get; set; } public required string SeriesName { get; set; } = default!; public DateTime ReadDate { get; set; } + public DateTime ReadDateUtc { get; set; } public int ChapterId { get; set; } public required float ChapterNumber { get; set; } = default!; } diff --git a/API/DTOs/Statistics/ServerStatisticsDto.cs b/API/DTOs/Statistics/ServerStatisticsDto.cs index 57fd5abce..3d22d9a56 100644 --- a/API/DTOs/Statistics/ServerStatisticsDto.cs +++ b/API/DTOs/Statistics/ServerStatisticsDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Statistics; #nullable enable -public class ServerStatisticsDto +public sealed record ServerStatisticsDto { public long ChapterCount { get; set; } public long VolumeCount { get; set; } diff --git a/API/DTOs/Statistics/TopReadsDto.cs b/API/DTOs/Statistics/TopReadsDto.cs index 806360533..d11594dca 100644 --- a/API/DTOs/Statistics/TopReadsDto.cs +++ b/API/DTOs/Statistics/TopReadsDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.Statistics; #nullable enable -public class TopReadDto +public sealed record TopReadDto { public int UserId { get; set; } public string? Username { get; set; } = default!; diff --git a/API/DTOs/Statistics/UserReadStatistics.cs b/API/DTOs/Statistics/UserReadStatistics.cs index 5e3f5aa5d..5c6935c6e 100644 --- a/API/DTOs/Statistics/UserReadStatistics.cs +++ b/API/DTOs/Statistics/UserReadStatistics.cs @@ -2,8 +2,9 @@ using System.Collections.Generic; namespace API.DTOs.Statistics; +#nullable enable -public class UserReadStatistics +public sealed record UserReadStatistics { /// /// Total number of pages read diff --git a/API/DTOs/Stats/FileExtensionExportDto.cs b/API/DTOs/Stats/FileExtensionExportDto.cs index 6ed554d75..e881960a5 100644 --- a/API/DTOs/Stats/FileExtensionExportDto.cs +++ b/API/DTOs/Stats/FileExtensionExportDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Stats; /// /// Excel export for File Extension Report /// -public class FileExtensionExportDto +public sealed record FileExtensionExportDto { [Name("Path")] public string FilePath { get; set; } diff --git a/API/DTOs/Stats/ServerInfoSlimDto.cs b/API/DTOs/Stats/ServerInfoSlimDto.cs index ef44bb408..f1abb2e1d 100644 --- a/API/DTOs/Stats/ServerInfoSlimDto.cs +++ b/API/DTOs/Stats/ServerInfoSlimDto.cs @@ -1,11 +1,12 @@ using System; namespace API.DTOs.Stats; +#nullable enable /// /// This is just for the Server tab on UI /// -public class ServerInfoSlimDto +public sealed record ServerInfoSlimDto { /// /// Unique Id that represents a unique install diff --git a/API/DTOs/Stats/V3/LibraryStatV3.cs b/API/DTOs/Stats/V3/LibraryStatV3.cs index 51af34b58..33ac86d2b 100644 --- a/API/DTOs/Stats/V3/LibraryStatV3.cs +++ b/API/DTOs/Stats/V3/LibraryStatV3.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.Stats.V3; -public class LibraryStatV3 +public sealed record LibraryStatV3 { public bool IncludeInDashboard { get; set; } public bool IncludeInSearch { get; set; } diff --git a/API/DTOs/Stats/V3/RelationshipStatV3.cs b/API/DTOs/Stats/V3/RelationshipStatV3.cs index e8e1e7440..37b63cb9a 100644 --- a/API/DTOs/Stats/V3/RelationshipStatV3.cs +++ b/API/DTOs/Stats/V3/RelationshipStatV3.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Stats.V3; /// /// KavitaStats - Information about Series Relationships /// -public class RelationshipStatV3 +public sealed record RelationshipStatV3 { public int Count { get; set; } public RelationKind Relationship { get; set; } diff --git a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs index edc2ad2b4..8ed3079f5 100644 --- a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs +++ b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.Stats.V3; /// /// Represents information about a Kavita Installation for Kavita Stats v3 API /// -public class ServerInfoV3Dto +public sealed record ServerInfoV3Dto { /// /// Unique Id that represents a unique install @@ -55,6 +55,11 @@ public class ServerInfoV3Dto /// /// This pings a health check and does not capture any IP Information public long TimeToPingKavitaStatsApi { get; set; } + /// + /// If using the downloading metadata feature + /// + /// Kavita+ Only + public bool MatchedMetadataEnabled { get; set; } diff --git a/API/DTOs/Stats/V3/UserStatV3.cs b/API/DTOs/Stats/V3/UserStatV3.cs index 7f4e080ba..450a2e409 100644 --- a/API/DTOs/Stats/V3/UserStatV3.cs +++ b/API/DTOs/Stats/V3/UserStatV3.cs @@ -5,7 +5,7 @@ using API.Entities.Enums.Device; namespace API.DTOs.Stats.V3; -public class UserStatV3 +public sealed record UserStatV3 { public AgeRestriction AgeRestriction { get; set; } /// diff --git a/API/DTOs/System/DirectoryDto.cs b/API/DTOs/System/DirectoryDto.cs index e6e94f4e4..3b1408f7f 100644 --- a/API/DTOs/System/DirectoryDto.cs +++ b/API/DTOs/System/DirectoryDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.System; -public class DirectoryDto +public sealed record DirectoryDto { /// /// Name of the directory diff --git a/API/DTOs/TachiyomiChapterDto.cs b/API/DTOs/TachiyomiChapterDto.cs index 03e242dfa..ecdd5115c 100644 --- a/API/DTOs/TachiyomiChapterDto.cs +++ b/API/DTOs/TachiyomiChapterDto.cs @@ -1,4 +1,5 @@ namespace API.DTOs; +#nullable enable /// /// This is explicitly for Tachiyomi. Number field was removed in v0.8.0, but Tachiyomi needs it for the hacks. diff --git a/API/DTOs/Theme/ColorScapeDto.cs b/API/DTOs/Theme/ColorScapeDto.cs index 066e87d84..2ebd96e2b 100644 --- a/API/DTOs/Theme/ColorScapeDto.cs +++ b/API/DTOs/Theme/ColorScapeDto.cs @@ -4,7 +4,7 @@ /// /// A set of colors for the color scape system in the UI /// -public class ColorScapeDto +public sealed record ColorScapeDto { public string? Primary { get; set; } public string? Secondary { get; set; } diff --git a/API/DTOs/Theme/DownloadableSiteThemeDto.cs b/API/DTOs/Theme/DownloadableSiteThemeDto.cs index dbcedfe61..b27263d92 100644 --- a/API/DTOs/Theme/DownloadableSiteThemeDto.cs +++ b/API/DTOs/Theme/DownloadableSiteThemeDto.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; namespace API.DTOs.Theme; -public class DownloadableSiteThemeDto +public sealed record DownloadableSiteThemeDto { /// /// Theme Name diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs index eb2a14904..7ae8369e9 100644 --- a/API/DTOs/Theme/SiteThemeDto.cs +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.Theme; /// /// Represents a set of css overrides the user can upload to Kavita and will load into webui /// -public class SiteThemeDto +public sealed record SiteThemeDto { public int Id { get; set; } /// diff --git a/API/DTOs/Theme/UpdateDefaultThemeDto.cs b/API/DTOs/Theme/UpdateDefaultThemeDto.cs index 0f2b129f3..aac0858c3 100644 --- a/API/DTOs/Theme/UpdateDefaultThemeDto.cs +++ b/API/DTOs/Theme/UpdateDefaultThemeDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Theme; -public class UpdateDefaultThemeDto +public sealed record UpdateDefaultThemeDto { public int ThemeId { get; set; } } diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index a83aa072b..b535684f0 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -1,9 +1,12 @@ -namespace API.DTOs.Update; +using System.Collections.Generic; +using System.Runtime.InteropServices.JavaScript; + +namespace API.DTOs.Update; /// /// Update Notification denoting a new release available for user to update to /// -public class UpdateNotificationDto +public sealed record UpdateNotificationDto { /// /// Current installed Version @@ -21,11 +24,11 @@ public class UpdateNotificationDto /// /// Title of the release /// - public required string UpdateTitle { get; init; } + public required string UpdateTitle { get; set; } /// /// Github Url /// - public required string UpdateUrl { get; init; } + public required string UpdateUrl { get; set; } /// /// If this install is within Docker /// @@ -37,7 +40,7 @@ public class UpdateNotificationDto /// /// Date of the publish /// - public required string PublishDate { get; init; } + public required string PublishDate { get; set; } /// /// Is the server on a nightly within this release /// @@ -50,4 +53,18 @@ public class UpdateNotificationDto /// Is the server on this version /// public bool IsReleaseEqual { get; set; } + + public IList Added { get; set; } + public IList Removed { get; set; } + public IList Changed { get; set; } + public IList Fixed { get; set; } + public IList Theme { get; set; } + public IList Developer { get; set; } + public IList Api { get; set; } + public IList FeatureRequests { get; set; } + public IList KnownIssues { get; set; } + /// + /// The part above the changelog part + /// + public string BlogPart { get; set; } } diff --git a/API/DTOs/UpdateChapterDto.cs b/API/DTOs/UpdateChapterDto.cs index 2ca0a12a9..9ead8adc8 100644 --- a/API/DTOs/UpdateChapterDto.cs +++ b/API/DTOs/UpdateChapterDto.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs; -public class UpdateChapterDto +public sealed record UpdateChapterDto { public int Id { get; init; } public string Summary { get; set; } = string.Empty; diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index 465782bd1..d7f314208 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs; -public class UpdateLibraryDto +public sealed record UpdateLibraryDto { [Required] public int Id { get; init; } @@ -26,6 +26,12 @@ public class UpdateLibraryDto public bool ManageReadingLists { get; init; } [Required] public bool AllowScrobbling { get; init; } + [Required] + public bool AllowMetadataMatching { get; init; } + [Required] + public bool EnableMetadata { get; init; } + [Required] + public bool RemovePrefixForSortName { get; init; } /// /// What types of files to allow the scanner to pickup /// diff --git a/API/DTOs/UpdateLibraryForUserDto.cs b/API/DTOs/UpdateLibraryForUserDto.cs index c90b697e2..4ce8d0df8 100644 --- a/API/DTOs/UpdateLibraryForUserDto.cs +++ b/API/DTOs/UpdateLibraryForUserDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class UpdateLibraryForUserDto +public sealed record UpdateLibraryForUserDto { public required string Username { get; init; } public required IEnumerable SelectedLibraries { get; init; } = new List(); diff --git a/API/DTOs/UpdateRBSDto.cs b/API/DTOs/UpdateRBSDto.cs index a7e0c3fc9..fa8bb78f9 100644 --- a/API/DTOs/UpdateRBSDto.cs +++ b/API/DTOs/UpdateRBSDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs; #nullable enable -public class UpdateRbsDto +public sealed record UpdateRbsDto { public required string Username { get; init; } public IList? Roles { get; init; } diff --git a/API/DTOs/UpdateSeriesRatingDto.cs b/API/DTOs/UpdateRatingDto.cs similarity index 58% rename from API/DTOs/UpdateSeriesRatingDto.cs rename to API/DTOs/UpdateRatingDto.cs index 5dafa35af..472a94fe9 100644 --- a/API/DTOs/UpdateSeriesRatingDto.cs +++ b/API/DTOs/UpdateRatingDto.cs @@ -1,7 +1,8 @@ namespace API.DTOs; -public class UpdateSeriesRatingDto +public sealed record UpdateRatingDto { public int SeriesId { get; init; } + public int? ChapterId { get; init; } public float UserRating { get; init; } } diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs index 52826f9d1..a4a9baf8c 100644 --- a/API/DTOs/UpdateSeriesDto.cs +++ b/API/DTOs/UpdateSeriesDto.cs @@ -1,6 +1,7 @@ namespace API.DTOs; +#nullable enable -public class UpdateSeriesDto +public sealed record UpdateSeriesDto { public int Id { get; init; } public string? LocalizedName { get; init; } diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs index 719a9459a..5225f5486 100644 --- a/API/DTOs/UpdateSeriesMetadataDto.cs +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs; -public class UpdateSeriesMetadataDto +public sealed record UpdateSeriesMetadataDto { - public SeriesMetadataDto SeriesMetadata { get; set; } = default!; + public SeriesMetadataDto SeriesMetadata { get; set; } = null!; } diff --git a/API/DTOs/Uploads/UploadFileDto.cs b/API/DTOs/Uploads/UploadFileDto.cs index 72fe7da9b..8d5cdf4cb 100644 --- a/API/DTOs/Uploads/UploadFileDto.cs +++ b/API/DTOs/Uploads/UploadFileDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Uploads; -public class UploadFileDto +public sealed record UploadFileDto { /// /// Id of the Entity diff --git a/API/DTOs/Uploads/UploadUrlDto.cs b/API/DTOs/Uploads/UploadUrlDto.cs index f2699befd..3f4e625c3 100644 --- a/API/DTOs/Uploads/UploadUrlDto.cs +++ b/API/DTOs/Uploads/UploadUrlDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Uploads; -public class UploadUrlDto +public sealed record UploadUrlDto { /// /// External url diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 7d54102da..88dc97a5d 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -1,10 +1,11 @@  +using System; using API.DTOs.Account; namespace API.DTOs; #nullable enable -public class UserDto +public sealed record UserDto { public string Username { get; init; } = null!; public string Email { get; init; } = null!; diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index d1cddf280..46f42306e 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -5,100 +5,10 @@ using API.Entities.Enums; using API.Entities.Enums.UserPreferences; namespace API.DTOs; +#nullable enable -public class UserPreferencesDto +public sealed record UserPreferencesDto { - /// - /// Manga Reader Option: What direction should the next/prev page buttons go - /// - [Required] - public ReadingDirection ReadingDirection { get; set; } - /// - /// Manga Reader Option: How should the image be scaled to screen - /// - [Required] - public ScalingOption ScalingOption { get; set; } - /// - /// Manga Reader Option: Which side of a split image should we show first - /// - [Required] - public PageSplitOption PageSplitOption { get; set; } - /// - /// Manga Reader Option: How the manga reader should perform paging or reading of the file - /// - /// Webtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging - /// by clicking top/bottom sides of reader. - /// - /// - [Required] - public ReaderMode ReaderMode { get; set; } - /// - /// Manga Reader Option: How many pages to display in the reader at once - /// - [Required] - public LayoutMode LayoutMode { get; set; } - /// - /// Manga Reader Option: Emulate a book by applying a shadow effect on the pages - /// - [Required] - public bool EmulateBook { get; set; } - /// - /// Manga Reader Option: Background color of the reader - /// - [Required] - public string BackgroundColor { get; set; } = "#000000"; - /// - /// Manga Reader Option: Should swiping trigger pagination - /// - [Required] - public bool SwipeToPaginate { get; set; } - /// - /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction - /// - [Required] - public bool AutoCloseMenu { get; set; } - /// - /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change - /// - [Required] - public bool ShowScreenHints { get; set; } = true; - /// - /// Book Reader Option: Override extra Margin - /// - [Required] - public int BookReaderMargin { get; set; } - /// - /// Book Reader Option: Override line-height - /// - [Required] - public int BookReaderLineSpacing { get; set; } - /// - /// Book Reader Option: Override font size - /// - [Required] - public int BookReaderFontSize { get; set; } - /// - /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override - /// - [Required] - public string BookReaderFontFamily { get; set; } = null!; - - /// - /// Book Reader Option: Allows tapping on side of screens to paginate - /// - [Required] - public bool BookReaderTapToPaginate { get; set; } - /// - /// Book Reader Option: What direction should the next/prev page buttons go - /// - [Required] - public ReadingDirection BookReaderReadingDirection { get; set; } - - /// - /// Book Reader Option: What writing style should be used, horizontal or vertical. - /// - [Required] - public WritingStyle BookReaderWritingStyle { get; set; } /// /// UI Site Global Setting: The UI theme the user should use. @@ -107,73 +17,28 @@ public class UserPreferencesDto [Required] public SiteThemeDto? Theme { get; set; } - [Required] public string BookReaderThemeName { get; set; } = null!; - [Required] - public BookPageLayoutMode BookReaderLayoutMode { get; set; } - /// - /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. - /// - /// Defaults to false - [Required] - public bool BookReaderImmersiveMode { get; set; } = false; - /// - /// Global Site Option: If the UI should layout items as Cards or List items - /// - /// Defaults to Cards - [Required] public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; - /// - /// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already - /// - /// Defaults to false + /// [Required] public bool BlurUnreadSummaries { get; set; } = false; - /// - /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. - /// + /// [Required] public bool PromptForDownloadSize { get; set; } = true; - /// - /// UI Site Global Setting: Should Kavita disable CSS transitions - /// + /// [Required] public bool NoTransitions { get; set; } = false; - /// - /// When showing series, only parent series or series with no relationships will be returned - /// + /// [Required] public bool CollapseSeriesRelationships { get; set; } = false; - /// - /// UI Site Global Setting: Should series reviews be shared with all users in the server - /// + /// [Required] public bool ShareReviews { get; set; } = false; - /// - /// UI Site Global Setting: The language locale that should be used for the user - /// + /// [Required] public string Locale { get; set; } - /// - /// PDF Reader: Theme of the Reader - /// - [Required] - public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; - /// - /// PDF Reader: Scroll mode of the reader - /// - [Required] - public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; - /// - /// PDF Reader: Layout Mode of the reader - /// - [Required] - public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple; - /// - /// PDF Reader: Spread Mode of the reader - /// - [Required] - public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; - - + /// + public bool AniListScrobblingEnabled { get; set; } + /// + public bool WantToReadSync { get; set; } } diff --git a/API/DTOs/UserReadingProfileDto.cs b/API/DTOs/UserReadingProfileDto.cs new file mode 100644 index 000000000..24dbf1c34 --- /dev/null +++ b/API/DTOs/UserReadingProfileDto.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Enums.UserPreferences; + +namespace API.DTOs; + +public sealed record UserReadingProfileDto +{ + + public int Id { get; set; } + public int UserId { get; init; } + + public string Name { get; init; } + public ReadingProfileKind Kind { get; init; } + + #region MangaReader + + /// + [Required] + public ReadingDirection ReadingDirection { get; set; } + + /// + [Required] + public ScalingOption ScalingOption { get; set; } + + /// + [Required] + public PageSplitOption PageSplitOption { get; set; } + + /// + [Required] + public ReaderMode ReaderMode { get; set; } + + /// + [Required] + public bool AutoCloseMenu { get; set; } + + /// + [Required] + public bool ShowScreenHints { get; set; } = true; + + /// + [Required] + public bool EmulateBook { get; set; } + + /// + [Required] + public LayoutMode LayoutMode { get; set; } + + /// + [Required] + public string BackgroundColor { get; set; } = "#000000"; + + /// + [Required] + public bool SwipeToPaginate { get; set; } + + /// + [Required] + public bool AllowAutomaticWebtoonReaderDetection { get; set; } + + /// + public int? WidthOverride { get; set; } + + /// + public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never; + + #endregion + + #region EpubReader + + /// + [Required] + public int BookReaderMargin { get; set; } + + /// + [Required] + public int BookReaderLineSpacing { get; set; } + + /// + [Required] + public int BookReaderFontSize { get; set; } + + /// + [Required] + public string BookReaderFontFamily { get; set; } = null!; + + /// + [Required] + public bool BookReaderTapToPaginate { get; set; } + + /// + [Required] + public ReadingDirection BookReaderReadingDirection { get; set; } + + /// + [Required] + public WritingStyle BookReaderWritingStyle { get; set; } + + /// + [Required] + public string BookReaderThemeName { get; set; } = null!; + + /// + [Required] + public BookPageLayoutMode BookReaderLayoutMode { get; set; } + + /// + [Required] + public bool BookReaderImmersiveMode { get; set; } = false; + + #endregion + + #region PdfReader + + /// + [Required] + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + + /// + [Required] + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + + /// + [Required] + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; + + #endregion + +} diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index f6413ff6f..fffccea59 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -1,5 +1,4 @@ - -using System; +using System; using System.Collections.Generic; using API.Entities; using API.Entities.Interfaces; @@ -8,14 +7,15 @@ using API.Services.Tasks.Scanner.Parser; namespace API.DTOs; -public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage +public sealed record VolumeDto : IHasReadTimeEstimate, IHasCoverImage { + /// public int Id { get; set; } - /// + /// public float MinNumber { get; set; } - /// + /// public float MaxNumber { get; set; } - /// + /// public string Name { get; set; } = default!; /// /// This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14 @@ -24,17 +24,21 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage public int Number { get; set; } public int Pages { get; set; } public int PagesRead { get; set; } + /// public DateTime LastModifiedUtc { get; set; } + /// public DateTime CreatedUtc { get; set; } /// /// When chapter was created in local server time /// /// This is required for Tachiyomi Extension + /// public DateTime Created { get; set; } /// /// When chapter was last modified in local server time /// /// This is required for Tachiyomi Extension + /// public DateTime LastModified { get; set; } public int SeriesId { get; set; } public ICollection Chapters { get; set; } = new List(); @@ -64,10 +68,14 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage return MinNumber.Is(Parser.SpecialVolumeNumber); } + /// public string CoverImage { get; set; } + /// private bool CoverImageLocked { get; set; } - public string PrimaryColor { get; set; } - public string SecondaryColor { get; set; } + /// + public string? PrimaryColor { get; set; } = string.Empty; + /// + public string? SecondaryColor { get; set; } = string.Empty; public void ResetColorScape() { diff --git a/API/DTOs/WantToRead/UpdateWantToReadDto.cs b/API/DTOs/WantToRead/UpdateWantToReadDto.cs index f1b38cea2..a5be26857 100644 --- a/API/DTOs/WantToRead/UpdateWantToReadDto.cs +++ b/API/DTOs/WantToRead/UpdateWantToReadDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.WantToRead; /// /// A list of Series to pass when working with Want To Read APIs /// -public class UpdateWantToReadDto +public sealed record UpdateWantToReadDto { /// /// List of Series Ids that will be Added/Removed diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 21b7c26c8..7d529b1da 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -1,12 +1,17 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; +using API.Entities.History; using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Entities.Scrobble; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; @@ -36,12 +41,13 @@ public sealed class DataContext : IdentityDbContext ServerSetting { get; set; } = null!; public DbSet AppUserPreferences { get; set; } = null!; public DbSet SeriesMetadata { get; set; } = null!; - [Obsolete] + [Obsolete("Use AppUserCollection")] public DbSet CollectionTag { get; set; } = null!; public DbSet AppUserBookmark { get; set; } = null!; public DbSet ReadingList { get; set; } = null!; public DbSet ReadingListItem { get; set; } = null!; public DbSet Person { get; set; } = null!; + public DbSet PersonAlias { get; set; } = null!; public DbSet Genre { get; set; } = null!; public DbSet Tag { get; set; } = null!; public DbSet SiteTheme { get; set; } = null!; @@ -64,11 +70,16 @@ public sealed class DataContext : IdentityDbContext ExternalSeriesMetadata { get; set; } = null!; public DbSet ExternalRecommendation { get; set; } = null!; public DbSet ManualMigrationHistory { get; set; } = null!; + [Obsolete("Use IsBlacklisted field on Series")] public DbSet SeriesBlacklist { get; set; } = null!; public DbSet AppUserCollection { get; set; } = null!; public DbSet ChapterPeople { get; set; } = null!; public DbSet SeriesMetadataPeople { get; set; } = null!; - + public DbSet EmailHistory { get; set; } = null!; + public DbSet MetadataSettings { get; set; } = null!; + public DbSet MetadataFieldMapping { get; set; } = null!; + public DbSet AppUserChapterRating { get; set; } = null!; + public DbSet AppUserReadingProfiles { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { @@ -118,10 +129,25 @@ public sealed class DataContext : IdentityDbContext b.Locale) .IsRequired(true) .HasDefaultValue("en"); + builder.Entity() + .Property(b => b.AniListScrobblingEnabled) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.WantToReadSync) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.AllowAutomaticWebtoonReaderDetection) + .HasDefaultValue(true); builder.Entity() .Property(b => b.AllowScrobbling) .HasDefaultValue(true); + builder.Entity() + .Property(b => b.AllowMetadataMatching) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableMetadata) + .HasDefaultValue(true); builder.Entity() .Property(b => b.WebLinks) @@ -187,6 +213,93 @@ public sealed class DataContext : IdentityDbContext p.SeriesMetadataPeople) .HasForeignKey(smp => smp.PersonId) .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.OrderWeight) + .HasDefaultValue(0); + + builder.Entity() + .Property(x => x.AgeRatingMappings) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new Dictionary() + ); + + // Ensure blacklist is stored as a JSON array + builder.Entity() + .Property(x => x.Blacklist) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + builder.Entity() + .Property(x => x.Whitelist) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + builder.Entity() + .Property(x => x.Overrides) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + + // Configure one-to-many relationship + builder.Entity() + .HasMany(x => x.FieldMappings) + .WithOne(x => x.MetadataSettings) + .HasForeignKey(x => x.MetadataSettingsId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.Enabled) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableCoverImage) + .HasDefaultValue(true); + + builder.Entity() + .Property(b => b.BookThemeName) + .HasDefaultValue("Dark"); + builder.Entity() + .Property(b => b.BackgroundColor) + .HasDefaultValue("#000000"); + builder.Entity() + .Property(b => b.BookReaderWritingStyle) + .HasDefaultValue(WritingStyle.Horizontal); + builder.Entity() + .Property(b => b.AllowAutomaticWebtoonReaderDetection) + .HasDefaultValue(true); + + builder.Entity() + .Property(rp => rp.LibraryIds) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasColumnType("TEXT"); + builder.Entity() + .Property(rp => rp.SeriesIds) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasColumnType("TEXT"); + + builder.Entity() + .Property(sm => sm.KPlusOverrides) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? + new List()) + .HasColumnType("TEXT") + .HasDefaultValue(new List()); + builder.Entity() + .Property(sm => sm.KPlusOverrides) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasColumnType("TEXT") + .HasDefaultValue(new List()); } #nullable enable diff --git a/API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs b/API/Data/ManualMigrations/v0.7.11/MigrateLibrariesToHaveAllFileTypes.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs rename to API/Data/ManualMigrations/v0.7.11/MigrateLibrariesToHaveAllFileTypes.cs diff --git a/API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs b/API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs similarity index 99% rename from API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs rename to API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs index e684ef6a0..d36859e69 100644 --- a/API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs +++ b/API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using API.DTOs.Filtering.v2; using API.Entities; +using API.Entities.History; using API.Helpers; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs b/API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs index 9eff55bc1..89485fd71 100644 --- a/API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateEmailTemplates.cs b/API/Data/ManualMigrations/v0.7.14/MigrateEmailTemplates.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateEmailTemplates.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateEmailTemplates.cs diff --git a/API/Data/ManualMigrations/MigrateManualHistory.cs b/API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateManualHistory.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs index b9ba1263c..eaf63c41c 100644 --- a/API/Data/ManualMigrations/MigrateManualHistory.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateVolumeLookupName.cs b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs similarity index 97% rename from API/Data/ManualMigrations/MigrateVolumeLookupName.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs index 9a2a4dbeb..38b7cfbba 100644 --- a/API/Data/ManualMigrations/MigrateVolumeLookupName.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateVolumeNumber.cs b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeNumber.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateVolumeNumber.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateVolumeNumber.cs diff --git a/API/Data/ManualMigrations/MigrateWantToReadExport.cs b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadExport.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateWantToReadExport.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateWantToReadExport.cs diff --git a/API/Data/ManualMigrations/MigrateWantToReadImport.cs b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadImport.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateWantToReadImport.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateWantToReadImport.cs diff --git a/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs b/API/Data/ManualMigrations/v0.7.9/MigrateUserLibrarySideNavStream.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs rename to API/Data/ManualMigrations/v0.7.9/MigrateUserLibrarySideNavStream.cs diff --git a/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs b/API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs similarity index 99% rename from API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs rename to API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs index 93fc569e8..fac184dc9 100644 --- a/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs +++ b/API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Extensions; using API.Helpers.Builders; using API.Services; diff --git a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs b/API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs similarity index 99% rename from API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs rename to API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs index 4e22abfb8..cda83f05b 100644 --- a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs +++ b/API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Extensions; using API.Helpers.Builders; using API.Services; diff --git a/API/Data/ManualMigrations/MigrateChapterFields.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs similarity index 99% rename from API/Data/ManualMigrations/MigrateChapterFields.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs index f157850fa..7d1f2dd12 100644 --- a/API/Data/ManualMigrations/MigrateChapterFields.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateChapterNumber.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateChapterNumber.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs index 23f256874..e31fa4b92 100644 --- a/API/Data/ManualMigrations/MigrateChapterNumber.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateChapterRange.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateChapterRange.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs index f50cd2e2e..70a4b30f6 100644 --- a/API/Data/ManualMigrations/MigrateChapterRange.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Extensions; using API.Helpers.Builders; using API.Services.Tasks.Scanner.Parser; diff --git a/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs b/API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs similarity index 99% rename from API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs index 038809aab..e29e706d0 100644 --- a/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; +using API.Entities.History; using API.Extensions.QueryExtensions; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs b/API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs index 2e31c3392..e414cd8cc 100644 --- a/API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateMangaFilePath.cs b/API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateMangaFilePath.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs index ccf9aa773..1dbc7f325 100644 --- a/API/Data/ManualMigrations/MigrateMangaFilePath.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateProgressExport.cs b/API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs similarity index 99% rename from API/Data/ManualMigrations/MigrateProgressExport.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs index 2482939c0..631daeea8 100644 --- a/API/Data/ManualMigrations/MigrateProgressExport.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services; using CsvHelper; using CsvHelper.Configuration.Attributes; diff --git a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs b/API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs rename to API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs index 48978d630..2a68ca3d6 100644 --- a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs +++ b/API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs b/API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs similarity index 98% rename from API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs rename to API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs index 8e648b025..21abfdf10 100644 --- a/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs +++ b/API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/ManualMigrateThemeDescription.cs b/API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs similarity index 98% rename from API/Data/ManualMigrations/ManualMigrateThemeDescription.cs rename to API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs index 8ac000f0d..e137afe7b 100644 --- a/API/Data/ManualMigrations/ManualMigrateThemeDescription.cs +++ b/API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateInitialInstallData.cs b/API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs similarity index 92% rename from API/Data/ManualMigrations/MigrateInitialInstallData.cs rename to API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs index f572034d1..851f4ac42 100644 --- a/API/Data/ManualMigrations/MigrateInitialInstallData.cs +++ b/API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs @@ -1,9 +1,11 @@ using System; +using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; +using API.Entities.History; using API.Services; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; @@ -34,7 +36,7 @@ public static class MigrateInitialInstallData { var fi = directoryService.FileSystem.FileInfo.New(dbFile); var setting = settings.First(s => s.Key == ServerSettingKey.FirstInstallDate); - setting.Value = fi.CreationTimeUtc.ToString(); + setting.Value = fi.CreationTimeUtc.ToString(CultureInfo.InvariantCulture); await dataContext.SaveChangesAsync(); } diff --git a/API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs b/API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs rename to API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs index ca68392cd..8e0db3c10 100644 --- a/API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs +++ b/API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; diff --git a/API/Data/ManualMigrations/ManualMigrateEncodeSettings.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs similarity index 98% rename from API/Data/ManualMigrations/ManualMigrateEncodeSettings.cs rename to API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs index e71f583ba..fc8b2e586 100644 --- a/API/Data/ManualMigrations/ManualMigrateEncodeSettings.cs +++ b/API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; +using API.Entities.History; using Flurl.Util; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/ManualMigrateRemovePeople.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs similarity index 98% rename from API/Data/ManualMigrations/ManualMigrateRemovePeople.cs rename to API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs index 6966c0264..01d9ad45d 100644 --- a/API/Data/ManualMigrations/ManualMigrateRemovePeople.cs +++ b/API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/ManualMigrateUnscrobbleBookLibraries.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs similarity index 98% rename from API/Data/ManualMigrations/ManualMigrateUnscrobbleBookLibraries.cs rename to API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs index 02c4886cb..4f0ed3f96 100644 --- a/API/Data/ManualMigrations/ManualMigrateUnscrobbleBookLibraries.cs +++ b/API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs b/API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs rename to API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs index bb79c3359..00233852a 100644 --- a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs +++ b/API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs new file mode 100644 index 000000000..60fd170fd --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Entities.Metadata; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - Migrating Kavita+ BlacklistedSeries table to Series entity to streamline implementation and generate a "Needs Manual Match" entry for the Series +/// +public static class ManualMigrateBlacklistTableToSeries +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateBlacklistTableToSeries")) + { + return; + } + + logger.LogCritical("Running ManualMigrateBlacklistTableToSeries migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var blacklistedSeries = await context.SeriesBlacklist + .Include(s => s.Series.ExternalSeriesMetadata) + .Select(s => s.Series) + .ToListAsync(); + + foreach (var series in blacklistedSeries) + { + series.IsBlacklisted = true; + series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata() { SeriesId = series.Id }; + + if (series.ExternalSeriesMetadata.AniListId > 0) + { + series.IsBlacklisted = false; + logger.LogInformation("{SeriesName} was in Blacklist table, but has valid AniList Id, not blacklisting", series.Name); + } + + context.Series.Entry(series).State = EntityState.Modified; + } + // Remove everything in SeriesBlacklist (it will be removed in another migration) + context.SeriesBlacklist.RemoveRange(context.SeriesBlacklist); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateBlacklistTableToSeries", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateBlacklistTableToSeries migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs new file mode 100644 index 000000000..14bc57cb1 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Entities.Metadata; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - Migrating Kavita+ Series that are Blacklisted but have valid ExternalSeries row +/// +public static class ManualMigrateInvalidBlacklistSeries +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateInvalidBlacklistSeries")) + { + return; + } + + logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var blacklistedSeries = await context.Series + .Include(s => s.ExternalSeriesMetadata) + .Where(s => s.IsBlacklisted && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue) + .ToListAsync(); + + foreach (var series in blacklistedSeries) + { + series.IsBlacklisted = false; + context.Series.Entry(series).State = EntityState.Modified; + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateInvalidBlacklistSeries", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateNeedsManualMatch.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateNeedsManualMatch.cs new file mode 100644 index 000000000..30e4a6d8e --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateNeedsManualMatch.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.KavitaPlus.Manage; +using API.Entities.History; +using API.Entities.Metadata; +using API.Extensions.QueryExtensions; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - After user testing, the needs manual match has some edge cases from migrations and for best user experience, +/// should be reset to allow the upgraded system to process better. +/// +public static class ManualMigrateNeedsManualMatch +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateNeedsManualMatch")) + { + return; + } + + logger.LogCritical("Running ManualMigrateNeedsManualMatch migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var series = await context.Series + .FilterMatchState(MatchStateOption.Error) + .ToListAsync(); + + foreach (var seriesEntry in series) + { + seriesEntry.IsBlacklisted = false; + context.Series.Update(seriesEntry); + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateNeedsManualMatch", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateNeedsManualMatch migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateScrobbleErrors.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateScrobbleErrors.cs new file mode 100644 index 000000000..b0d483de6 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateScrobbleErrors.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - There seems to be some scrobble events that are pre-scrobble error table that can be processed over and over. +/// This will take the given year and minus 1 from it and clear everything from that and anything that is errored. +/// +public static class ManualMigrateScrobbleErrors +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateScrobbleErrors")) + { + return; + } + + logger.LogCritical("Running ManualMigrateScrobbleErrors migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var events = await context.ScrobbleEvent + .Where(se => se.LastModifiedUtc <= DateTime.UtcNow.AddYears(-1) || se.IsErrored) + .ToListAsync(); + + context.ScrobbleEvent.RemoveRange(events); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + logger.LogInformation("Removed {Count} old scrobble events", events.Count); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateScrobbleErrors", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateScrobbleErrors migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/MigrateProgressExport.cs b/API/Data/ManualMigrations/v0.8.5/MigrateProgressExport.cs new file mode 100644 index 000000000..67488d337 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/MigrateProgressExport.cs @@ -0,0 +1,80 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Services; +using CsvHelper; +using CsvHelper.Configuration.Attributes; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + + +/// +/// v0.8.5 - Progress is extracted and saved in a csv since PDF parser has massive changes +/// +public static class MigrateProgressExportForV085 +{ + public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger logger) + { + try + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateProgressExportForV085")) + { + return; + } + + logger.LogCritical( + "Running MigrateProgressExportForV085 migration - Please be patient, this may take some time. This is not an error"); + + var data = await dataContext.AppUserProgresses + .Join(dataContext.Series, progress => progress.SeriesId, series => series.Id, (progress, series) => new { progress, series }) + .Join(dataContext.Volume, ps => ps.progress.VolumeId, volume => volume.Id, (ps, volume) => new { ps.progress, ps.series, volume }) + .Join(dataContext.Chapter, psv => psv.progress.ChapterId, chapter => chapter.Id, (psv, chapter) => new { psv.progress, psv.series, psv.volume, chapter }) + .Join(dataContext.MangaFile, psvc => psvc.chapter.Id, mangaFile => mangaFile.ChapterId, (psvc, mangaFile) => new { psvc.progress, psvc.series, psvc.volume, psvc.chapter, mangaFile }) + .Join(dataContext.AppUser, psvcm => psvcm.progress.AppUserId, appUser => appUser.Id, (psvcm, appUser) => new + { + LibraryId = psvcm.series.LibraryId, + LibraryName = psvcm.series.Library.Name, + SeriesName = psvcm.series.Name, + VolumeRange = psvcm.volume.MinNumber + "-" + psvcm.volume.MaxNumber, + VolumeLookupName = psvcm.volume.Name, + ChapterRange = psvcm.chapter.Range, + MangaFileName = psvcm.mangaFile.FileName, + MangaFilePath = psvcm.mangaFile.FilePath, + AppUserName = appUser.UserName, + AppUserId = appUser.Id, + PagesRead = psvcm.progress.PagesRead, + BookScrollId = psvcm.progress.BookScrollId, + ProgressCreated = psvcm.progress.Created, + ProgressLastModified = psvcm.progress.LastModified + }).ToListAsync(); + + + // Write the mapped data to a CSV file + await using var writer = new StreamWriter(Path.Join(directoryService.ConfigDirectory, "progress_export-v0.8.5.csv")); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + await csv.WriteRecordsAsync(data); + + logger.LogCritical( + "Running MigrateProgressExportForV085 migration - Completed. This is not an error"); + } + catch (Exception ex) + { + // On new installs, the db isn't setup yet, so this has nothing to do + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateProgressExportForV085", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + } +} diff --git a/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleEventGen.cs b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleEventGen.cs new file mode 100644 index 000000000..eb51d0fe6 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleEventGen.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.6 - Manually check when a user triggers scrobble event generation +/// +public static class ManualMigrateScrobbleEventGen +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateScrobbleEventGen")) + { + return; + } + + logger.LogCritical("Running ManualMigrateScrobbleEventGen migration - Please be patient, this may take some time. This is not an error"); + + var users = await context.Users + .Where(u => u.AniListAccessToken != null) + .ToListAsync(); + + foreach (var user in users) + { + if (await context.ScrobbleEvent.AnyAsync(se => se.AppUserId == user.Id)) + { + user.HasRunScrobbleEventGeneration = true; + user.ScrobbleEventGenerationRan = DateTime.UtcNow; + context.AppUser.Update(user); + } + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateScrobbleEventGen", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateScrobbleEventGen migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleSpecials.cs b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleSpecials.cs new file mode 100644 index 000000000..4749ff2ec --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleSpecials.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.6 - Change to not scrobble specials as they will never process, this migration removes all existing scrobble events +/// +public static class ManualMigrateScrobbleSpecials +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateScrobbleSpecials")) + { + return; + } + + logger.LogCritical("Running ManualMigrateScrobbleSpecials migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var events = await context.ScrobbleEvent + .Where(se => se.VolumeNumber == Parser.SpecialVolumeNumber) + .ToListAsync(); + + context.ScrobbleEvent.RemoveRange(events); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + logger.LogInformation("Removed {Count} scrobble events that were specials", events.Count); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateScrobbleSpecials", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateScrobbleSpecials migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs new file mode 100644 index 000000000..b2afde98a --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.Enums; +using API.Entities.History; +using API.Extensions; +using API.Helpers.Builders; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +public static class ManualMigrateReadingProfiles +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateReadingProfiles")) + { + return; + } + + logger.LogCritical("Running ManualMigrateReadingProfiles migration - Please be patient, this may take some time. This is not an error"); + + var users = await context.AppUser + .Include(u => u.UserPreferences) + .Include(u => u.ReadingProfiles) + .ToListAsync(); + + foreach (var user in users) + { + var readingProfile = new AppUserReadingProfile + { + Name = "Default", + NormalizedName = "Default".ToNormalized(), + Kind = ReadingProfileKind.Default, + LibraryIds = [], + SeriesIds = [], + BackgroundColor = user.UserPreferences.BackgroundColor, + EmulateBook = user.UserPreferences.EmulateBook, + AppUser = user, + PdfTheme = user.UserPreferences.PdfTheme, + ReaderMode = user.UserPreferences.ReaderMode, + ReadingDirection = user.UserPreferences.ReadingDirection, + ScalingOption = user.UserPreferences.ScalingOption, + LayoutMode = user.UserPreferences.LayoutMode, + WidthOverride = null, + AppUserId = user.Id, + AutoCloseMenu = user.UserPreferences.AutoCloseMenu, + BookReaderMargin = user.UserPreferences.BookReaderMargin, + PageSplitOption = user.UserPreferences.PageSplitOption, + BookThemeName = user.UserPreferences.BookThemeName, + PdfSpreadMode = user.UserPreferences.PdfSpreadMode, + PdfScrollMode = user.UserPreferences.PdfScrollMode, + SwipeToPaginate = user.UserPreferences.SwipeToPaginate, + BookReaderFontFamily = user.UserPreferences.BookReaderFontFamily, + BookReaderFontSize = user.UserPreferences.BookReaderFontSize, + BookReaderImmersiveMode = user.UserPreferences.BookReaderImmersiveMode, + BookReaderLayoutMode = user.UserPreferences.BookReaderLayoutMode, + BookReaderLineSpacing = user.UserPreferences.BookReaderLineSpacing, + BookReaderReadingDirection = user.UserPreferences.BookReaderReadingDirection, + BookReaderWritingStyle = user.UserPreferences.BookReaderWritingStyle, + AllowAutomaticWebtoonReaderDetection = user.UserPreferences.AllowAutomaticWebtoonReaderDetection, + BookReaderTapToPaginate = user.UserPreferences.BookReaderTapToPaginate, + ShowScreenHints = user.UserPreferences.ShowScreenHints, + }; + user.ReadingProfiles.Add(readingProfile); + } + + await context.SaveChangesAsync(); + + context.ManualMigrationHistory.Add(new ManualMigrationHistory + { + Name = "ManualMigrateReadingProfiles", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow, + }); + await context.SaveChangesAsync(); + + + logger.LogCritical("Running ManualMigrateReadingProfiles migration - Completed. This is not an error"); + + } +} diff --git a/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.Designer.cs b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.Designer.cs new file mode 100644 index 000000000..a5158ebc1 --- /dev/null +++ b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.Designer.cs @@ -0,0 +1,3203 @@ +// +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("20250105180131_SeriesDontMatchAndBlacklist")] + partial class SeriesDontMatchAndBlacklist + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.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("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.cs b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.cs new file mode 100644 index 000000000..ab80f0621 --- /dev/null +++ b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SeriesDontMatchAndBlacklist : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DontMatch", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsBlacklisted", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DontMatch", + table: "Series"); + + migrationBuilder.DropColumn( + name: "IsBlacklisted", + table: "Series"); + } + } +} diff --git a/API/Data/Migrations/20250109173537_EmailHistory.Designer.cs b/API/Data/Migrations/20250109173537_EmailHistory.Designer.cs new file mode 100644 index 000000000..ff3212562 --- /dev/null +++ b/API/Data/Migrations/20250109173537_EmailHistory.Designer.cs @@ -0,0 +1,3265 @@ +// +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("20250109173537_EmailHistory")] + partial class EmailHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.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("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250109173537_EmailHistory.cs b/API/Data/Migrations/20250109173537_EmailHistory.cs new file mode 100644 index 000000000..b31bf20c3 --- /dev/null +++ b/API/Data/Migrations/20250109173537_EmailHistory.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class EmailHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EmailHistory", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Sent = table.Column(type: "INTEGER", nullable: false), + SendDate = table.Column(type: "TEXT", nullable: false), + EmailTemplate = table.Column(type: "TEXT", nullable: true), + Subject = table.Column(type: "TEXT", nullable: true), + Body = table.Column(type: "TEXT", nullable: true), + DeliveryStatus = table.Column(type: "TEXT", nullable: true), + ErrorMessage = table.Column(type: "TEXT", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + LastModifiedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailHistory", x => x.Id); + table.ForeignKey( + name: "FK_EmailHistory_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_EmailHistory_AppUserId", + table: "EmailHistory", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_EmailHistory_Sent_AppUserId_EmailTemplate_SendDate", + table: "EmailHistory", + columns: new[] { "Sent", "AppUserId", "EmailTemplate", "SendDate" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmailHistory"); + } + } +} diff --git a/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs new file mode 100644 index 000000000..835510a1e --- /dev/null +++ b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs @@ -0,0 +1,3382 @@ +// +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("20250202163454_KavitaPlusUserAndMetadataSettings")] + partial class KavitaPlusUserAndMetadataSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs new file mode 100644 index 000000000..b23d7896b --- /dev/null +++ b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs @@ -0,0 +1,112 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class KavitaPlusUserAndMetadataSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowMetadataMatching", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "AniListScrobblingEnabled", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "WantToReadSync", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.CreateTable( + name: "MetadataSettings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + EnableSummary = table.Column(type: "INTEGER", nullable: false), + EnablePublicationStatus = table.Column(type: "INTEGER", nullable: false), + EnableRelationships = table.Column(type: "INTEGER", nullable: false), + EnablePeople = table.Column(type: "INTEGER", nullable: false), + EnableStartDate = table.Column(type: "INTEGER", nullable: false), + EnableLocalizedName = table.Column(type: "INTEGER", nullable: false), + EnableGenres = table.Column(type: "INTEGER", nullable: false), + EnableTags = table.Column(type: "INTEGER", nullable: false), + FirstLastPeopleNaming = table.Column(type: "INTEGER", nullable: false), + AgeRatingMappings = table.Column(type: "TEXT", nullable: true), + Blacklist = table.Column(type: "TEXT", nullable: true), + Whitelist = table.Column(type: "TEXT", nullable: true), + PersonRoles = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MetadataSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MetadataFieldMapping", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SourceType = table.Column(type: "INTEGER", nullable: false), + DestinationType = table.Column(type: "INTEGER", nullable: false), + SourceValue = table.Column(type: "TEXT", nullable: true), + DestinationValue = table.Column(type: "TEXT", nullable: true), + ExcludeFromSource = table.Column(type: "INTEGER", nullable: false), + MetadataSettingsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MetadataFieldMapping", x => x.Id); + table.ForeignKey( + name: "FK_MetadataFieldMapping_MetadataSettings_MetadataSettingsId", + column: x => x.MetadataSettingsId, + principalTable: "MetadataSettings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MetadataFieldMapping_MetadataSettingsId", + table: "MetadataFieldMapping", + column: "MetadataSettingsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MetadataFieldMapping"); + + migrationBuilder.DropTable( + name: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "AllowMetadataMatching", + table: "Library"); + + migrationBuilder.DropColumn( + name: "AniListScrobblingEnabled", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "WantToReadSync", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs new file mode 100644 index 000000000..9aaa63101 --- /dev/null +++ b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs @@ -0,0 +1,3398 @@ +// +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("20250208200843_MoreMetadtaSettings")] + partial class MoreMetadtaSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs new file mode 100644 index 000000000..70e42cd11 --- /dev/null +++ b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class MoreMetadtaSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "KavitaPlusConnection", + table: "SeriesMetadataPeople", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "OrderWeight", + table: "SeriesMetadataPeople", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "EnableCoverImage", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "Overrides", + table: "MetadataSettings", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "KavitaPlusConnection", + table: "SeriesMetadataPeople"); + + migrationBuilder.DropColumn( + name: "OrderWeight", + table: "SeriesMetadataPeople"); + + migrationBuilder.DropColumn( + name: "EnableCoverImage", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "Overrides", + table: "MetadataSettings"); + } + } +} diff --git a/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs new file mode 100644 index 000000000..be3d5e3f9 --- /dev/null +++ b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs @@ -0,0 +1,3403 @@ +// +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("20250328125012_AutomaticWebtoonReaderMode")] + partial class AutomaticWebtoonReaderMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs new file mode 100644 index 000000000..38b772811 --- /dev/null +++ b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AutomaticWebtoonReaderMode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowAutomaticWebtoonReaderDetection", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllowAutomaticWebtoonReaderDetection", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.Designer.cs b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.Designer.cs new file mode 100644 index 000000000..53e450b3b --- /dev/null +++ b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.Designer.cs @@ -0,0 +1,3409 @@ +// +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("20250408222330_ScrobbleGenerationDbCapture")] + partial class ScrobbleGenerationDbCapture + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.cs b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.cs new file mode 100644 index 000000000..7431a7338 --- /dev/null +++ b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ScrobbleGenerationDbCapture : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HasRunScrobbleEventGeneration", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ScrobbleEventGenerationRan", + table: "AspNetUsers", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HasRunScrobbleEventGeneration", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "ScrobbleEventGenerationRan", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/20250415194829_KavitaPlusCBR.Designer.cs b/API/Data/Migrations/20250415194829_KavitaPlusCBR.Designer.cs new file mode 100644 index 000000000..fd287c085 --- /dev/null +++ b/API/Data/Migrations/20250415194829_KavitaPlusCBR.Designer.cs @@ -0,0 +1,3433 @@ +// +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("20250415194829_KavitaPlusCBR")] + partial class KavitaPlusCBR + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250415194829_KavitaPlusCBR.cs b/API/Data/Migrations/20250415194829_KavitaPlusCBR.cs new file mode 100644 index 000000000..188969476 --- /dev/null +++ b/API/Data/Migrations/20250415194829_KavitaPlusCBR.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class KavitaPlusCBR : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableChapterCoverImage", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterPublisher", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterReleaseDate", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterSummary", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterTitle", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CbrId", + table: "ExternalSeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "KavitaPlusConnection", + table: "ChapterPeople", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "OrderWeight", + table: "ChapterPeople", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableChapterCoverImage", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterPublisher", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterReleaseDate", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterSummary", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterTitle", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "CbrId", + table: "ExternalSeriesMetadata"); + + migrationBuilder.DropColumn( + name: "KavitaPlusConnection", + table: "ChapterPeople"); + + migrationBuilder.DropColumn( + name: "OrderWeight", + table: "ChapterPeople"); + } + } +} diff --git a/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.Designer.cs b/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.Designer.cs new file mode 100644 index 000000000..52e2c4a86 --- /dev/null +++ b/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.Designer.cs @@ -0,0 +1,3536 @@ +// +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("20250429150140_ChapterRatingAndReviews")] + partial class ChapterRatingAndReviews + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.cs b/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.cs new file mode 100644 index 000000000..5ab51aaba --- /dev/null +++ b/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.cs @@ -0,0 +1,165 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ChapterRatingAndReviews : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Authority", + table: "ExternalReview", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ChapterId", + table: "ExternalReview", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Authority", + table: "ExternalRating", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ChapterId", + table: "ExternalRating", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "AverageExternalRating", + table: "Chapter", + type: "REAL", + nullable: false, + defaultValue: 0f); + + migrationBuilder.CreateTable( + name: "AppUserChapterRating", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Rating = table.Column(type: "REAL", nullable: false), + HasBeenRated = table.Column(type: "INTEGER", nullable: false), + Review = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "INTEGER", nullable: false), + ChapterId = table.Column(type: "INTEGER", nullable: false), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserChapterRating", x => x.Id); + table.ForeignKey( + name: "FK_AppUserChapterRating_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserChapterRating_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserChapterRating_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExternalReview_ChapterId", + table: "ExternalReview", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_ExternalRating_ChapterId", + table: "ExternalRating", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserChapterRating_AppUserId", + table: "AppUserChapterRating", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserChapterRating_ChapterId", + table: "AppUserChapterRating", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserChapterRating_SeriesId", + table: "AppUserChapterRating", + column: "SeriesId"); + + migrationBuilder.AddForeignKey( + name: "FK_ExternalRating_Chapter_ChapterId", + table: "ExternalRating", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_ExternalReview_Chapter_ChapterId", + table: "ExternalReview", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ExternalRating_Chapter_ChapterId", + table: "ExternalRating"); + + migrationBuilder.DropForeignKey( + name: "FK_ExternalReview_Chapter_ChapterId", + table: "ExternalReview"); + + migrationBuilder.DropTable( + name: "AppUserChapterRating"); + + migrationBuilder.DropIndex( + name: "IX_ExternalReview_ChapterId", + table: "ExternalReview"); + + migrationBuilder.DropIndex( + name: "IX_ExternalRating_ChapterId", + table: "ExternalRating"); + + migrationBuilder.DropColumn( + name: "Authority", + table: "ExternalReview"); + + migrationBuilder.DropColumn( + name: "ChapterId", + table: "ExternalReview"); + + migrationBuilder.DropColumn( + name: "Authority", + table: "ExternalRating"); + + migrationBuilder.DropColumn( + name: "ChapterId", + table: "ExternalRating"); + + migrationBuilder.DropColumn( + name: "AverageExternalRating", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs b/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs new file mode 100644 index 000000000..5d76571e1 --- /dev/null +++ b/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs @@ -0,0 +1,3571 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250507221026_PersonAliases")] + partial class PersonAliases + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250507221026_PersonAliases.cs b/API/Data/Migrations/20250507221026_PersonAliases.cs new file mode 100644 index 000000000..cb046a131 --- /dev/null +++ b/API/Data/Migrations/20250507221026_PersonAliases.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PersonAliases : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PersonAlias", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Alias = table.Column(type: "TEXT", nullable: true), + NormalizedAlias = table.Column(type: "TEXT", nullable: true), + PersonId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PersonAlias", x => x.Id); + table.ForeignKey( + name: "FK_PersonAlias_Person_PersonId", + column: x => x.PersonId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PersonAlias_PersonId", + table: "PersonAlias", + column: "PersonId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PersonAlias"); + } + } +} diff --git a/API/Data/Migrations/20250519151126_KoreaderHash.Designer.cs b/API/Data/Migrations/20250519151126_KoreaderHash.Designer.cs new file mode 100644 index 000000000..79f6f9504 --- /dev/null +++ b/API/Data/Migrations/20250519151126_KoreaderHash.Designer.cs @@ -0,0 +1,3574 @@ +// +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("20250519151126_KoreaderHash")] + partial class KoreaderHash + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250519151126_KoreaderHash.cs b/API/Data/Migrations/20250519151126_KoreaderHash.cs new file mode 100644 index 000000000..006070b72 --- /dev/null +++ b/API/Data/Migrations/20250519151126_KoreaderHash.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class KoreaderHash : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "KoreaderHash", + table: "MangaFile", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "KoreaderHash", + table: "MangaFile"); + } + } +} diff --git a/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs b/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs new file mode 100644 index 000000000..762eae142 --- /dev/null +++ b/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs @@ -0,0 +1,3698 @@ +// +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("20250601200056_ReadingProfiles")] + partial class ReadingProfiles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250601200056_ReadingProfiles.cs b/API/Data/Migrations/20250601200056_ReadingProfiles.cs new file mode 100644 index 000000000..66b9e53e5 --- /dev/null +++ b/API/Data/Migrations/20250601200056_ReadingProfiles.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ReadingProfiles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserReadingProfiles", + 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), + AppUserId = table.Column(type: "INTEGER", nullable: false), + Kind = table.Column(type: "INTEGER", nullable: false), + LibraryIds = table.Column(type: "TEXT", nullable: true), + SeriesIds = table.Column(type: "TEXT", nullable: true), + ReadingDirection = table.Column(type: "INTEGER", nullable: false), + ScalingOption = table.Column(type: "INTEGER", nullable: false), + PageSplitOption = table.Column(type: "INTEGER", nullable: false), + ReaderMode = table.Column(type: "INTEGER", nullable: false), + AutoCloseMenu = table.Column(type: "INTEGER", nullable: false), + ShowScreenHints = table.Column(type: "INTEGER", nullable: false), + EmulateBook = table.Column(type: "INTEGER", nullable: false), + LayoutMode = table.Column(type: "INTEGER", nullable: false), + BackgroundColor = table.Column(type: "TEXT", nullable: true, defaultValue: "#000000"), + SwipeToPaginate = table.Column(type: "INTEGER", nullable: false), + AllowAutomaticWebtoonReaderDetection = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + WidthOverride = table.Column(type: "INTEGER", nullable: true), + BookReaderMargin = table.Column(type: "INTEGER", nullable: false), + BookReaderLineSpacing = table.Column(type: "INTEGER", nullable: false), + BookReaderFontSize = table.Column(type: "INTEGER", nullable: false), + BookReaderFontFamily = table.Column(type: "TEXT", nullable: true), + BookReaderTapToPaginate = table.Column(type: "INTEGER", nullable: false), + BookReaderReadingDirection = table.Column(type: "INTEGER", nullable: false), + BookReaderWritingStyle = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + BookThemeName = table.Column(type: "TEXT", nullable: true, defaultValue: "Dark"), + BookReaderLayoutMode = table.Column(type: "INTEGER", nullable: false), + BookReaderImmersiveMode = table.Column(type: "INTEGER", nullable: false), + PdfTheme = table.Column(type: "INTEGER", nullable: false), + PdfScrollMode = table.Column(type: "INTEGER", nullable: false), + PdfSpreadMode = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserReadingProfiles", x => x.Id); + table.ForeignKey( + name: "FK_AppUserReadingProfiles_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserReadingProfiles_AppUserId", + table: "AppUserReadingProfiles", + column: "AppUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserReadingProfiles"); + } + } +} diff --git a/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs new file mode 100644 index 000000000..0e9f00b4e --- /dev/null +++ b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs @@ -0,0 +1,3701 @@ +// +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("20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint")] + partial class AppUserReadingProfileDisableWidthOverrideBreakPoint + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs new file mode 100644 index 000000000..11a554bdf --- /dev/null +++ b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AppUserReadingProfileDisableWidthOverrideBreakPoint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisableWidthOverride", + table: "AppUserReadingProfiles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DisableWidthOverride", + table: "AppUserReadingProfiles"); + } + } +} diff --git a/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs new file mode 100644 index 000000000..c15f9f77b --- /dev/null +++ b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs @@ -0,0 +1,3709 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250620215058_EnableMetadataLibrary")] + partial class EnableMetadataLibrary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs new file mode 100644 index 000000000..f9e38c01d --- /dev/null +++ b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class EnableMetadataLibrary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableMetadata", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableMetadata", + table: "Library"); + } + } +} diff --git a/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.Designer.cs b/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.Designer.cs new file mode 100644 index 000000000..b72239924 --- /dev/null +++ b/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.Designer.cs @@ -0,0 +1,3721 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +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("20250626162548_TrackKavitaPlusMetadata")] + partial class TrackKavitaPlusMetadata + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.cs b/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.cs new file mode 100644 index 000000000..ac253e0a8 --- /dev/null +++ b/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class TrackKavitaPlusMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "KPlusOverrides", + table: "SeriesMetadata", + type: "TEXT", + nullable: true, + defaultValue: "[]"); + + migrationBuilder.AddColumn( + name: "KPlusOverrides", + table: "Chapter", + type: "TEXT", + nullable: true, + defaultValue: "[]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "KPlusOverrides", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "KPlusOverrides", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.Designer.cs b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.Designer.cs new file mode 100644 index 000000000..165663f3d --- /dev/null +++ b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.Designer.cs @@ -0,0 +1,3724 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +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("20250629153840_LibraryRemoveSortPrefix")] + partial class LibraryRemoveSortPrefix + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.cs b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.cs new file mode 100644 index 000000000..4800cf3fa --- /dev/null +++ b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class LibraryRemoveSortPrefix : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RemovePrefixForSortName", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RemovePrefixForSortName", + table: "Library"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index ddcfeb10e..62d1fb1ef 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1,6 +1,8 @@ // using System; +using System.Collections.Generic; using API.Data; +using API.Entities.MetadataMatching; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -15,7 +17,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -85,6 +87,9 @@ namespace API.Data.Migrations b.Property("EmailConfirmed") .HasColumnType("INTEGER"); + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + b.Property("LastActive") .HasColumnType("TEXT"); @@ -124,6 +129,9 @@ namespace API.Data.Migrations .IsConcurrencyToken() .HasColumnType("INTEGER"); + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + b.Property("SecurityStamp") .HasColumnType("TEXT"); @@ -189,6 +197,41 @@ namespace API.Data.Migrations b.ToTable("AppUserBookmark"); }); + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + modelBuilder.Entity("API.Entities.AppUserCollection", b => { b.Property("Id") @@ -353,6 +396,16 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("AppUserId") .HasColumnType("INTEGER"); @@ -460,6 +513,11 @@ namespace API.Data.Migrations b.Property("ThemeId") .HasColumnType("INTEGER"); + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.HasKey("Id"); b.HasIndex("AppUserId") @@ -553,6 +611,123 @@ namespace API.Data.Migrations b.ToTable("AppUserRating"); }); + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + modelBuilder.Entity("API.Entities.AppUserRole", b => { b.Property("UserId") @@ -731,6 +906,9 @@ namespace API.Data.Migrations b.Property("AlternateSeries") .HasColumnType("TEXT"); + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + b.Property("AvgHoursToRead") .HasColumnType("REAL"); @@ -781,6 +959,11 @@ namespace API.Data.Migrations b.Property("IsSpecial") .HasColumnType("INTEGER"); + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + b.Property("Language") .HasColumnType("TEXT"); @@ -901,24 +1084,6 @@ namespace API.Data.Migrations b.ToTable("Chapter"); }); - modelBuilder.Entity("API.Entities.ChapterPeople", b => - { - b.Property("ChapterId") - .HasColumnType("INTEGER"); - - b.Property("PersonId") - .HasColumnType("INTEGER"); - - b.Property("Role") - .HasColumnType("INTEGER"); - - b.HasKey("ChapterId", "PersonId", "Role"); - - b.HasIndex("PersonId"); - - b.ToTable("ChapterPeople"); - }); - modelBuilder.Entity("API.Entities.CollectionTag", b => { b.Property("Id") @@ -1000,6 +1165,57 @@ namespace API.Data.Migrations b.ToTable("Device"); }); + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.Property("Id") @@ -1042,12 +1258,37 @@ namespace API.Data.Migrations b.ToTable("Genre"); }); + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + modelBuilder.Entity("API.Entities.Library", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("AllowScrobbling") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -1062,6 +1303,11 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("FolderWatching") .HasColumnType("INTEGER"); @@ -1095,6 +1341,9 @@ namespace API.Data.Migrations b.Property("PrimaryColor") .HasColumnType("TEXT"); + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + b.Property("SecondaryColor") .HasColumnType("TEXT"); @@ -1174,6 +1423,9 @@ namespace API.Data.Migrations b.Property("Format") .HasColumnType("INTEGER"); + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + b.Property("LastFileAnalysis") .HasColumnType("TEXT"); @@ -1196,26 +1448,6 @@ namespace API.Data.Migrations b.ToTable("MangaFile"); }); - modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Name") - .HasColumnType("TEXT"); - - b.Property("ProductVersion") - .HasColumnType("TEXT"); - - b.Property("RanAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("ManualMigrationHistory"); - }); - modelBuilder.Entity("API.Entities.MediaError", b => { b.Property("Id") @@ -1257,9 +1489,15 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Authority") + .HasColumnType("INTEGER"); + b.Property("AverageScore") .HasColumnType("INTEGER"); + b.Property("ChapterId") + .HasColumnType("INTEGER"); + b.Property("FavoriteCount") .HasColumnType("INTEGER"); @@ -1274,6 +1512,8 @@ namespace API.Data.Migrations b.HasKey("Id"); + b.HasIndex("ChapterId"); + b.ToTable("ExternalRating"); }); @@ -1320,12 +1560,18 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Authority") + .HasColumnType("INTEGER"); + b.Property("Body") .HasColumnType("TEXT"); b.Property("BodyJustText") .HasColumnType("TEXT"); + b.Property("ChapterId") + .HasColumnType("INTEGER"); + b.Property("Provider") .HasColumnType("INTEGER"); @@ -1355,6 +1601,8 @@ namespace API.Data.Migrations b.HasKey("Id"); + b.HasIndex("ChapterId"); + b.ToTable("ExternalReview"); }); @@ -1370,6 +1618,9 @@ namespace API.Data.Migrations b.Property("AverageExternalRating") .HasColumnType("INTEGER"); + b.Property("CbrId") + .HasColumnType("INTEGER"); + b.Property("GoogleBooksId") .HasColumnType("TEXT"); @@ -1442,6 +1693,11 @@ namespace API.Data.Migrations b.Property("InkerLocked") .HasColumnType("INTEGER"); + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + b.Property("Language") .HasColumnType("TEXT"); @@ -1543,7 +1799,140 @@ namespace API.Data.Migrations b.ToTable("SeriesRelation"); }); - modelBuilder.Entity("API.Entities.Person", b => + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1587,6 +1976,54 @@ namespace API.Data.Migrations b.ToTable("Person"); }); + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.Property("Id") @@ -1866,12 +2303,18 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("DontMatch") + .HasColumnType("INTEGER"); + b.Property("FolderPath") .HasColumnType("TEXT"); b.Property("Format") .HasColumnType("INTEGER"); + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + b.Property("LastChapterAdded") .HasColumnType("TEXT"); @@ -1945,24 +2388,6 @@ namespace API.Data.Migrations b.ToTable("Series"); }); - modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => - { - b.Property("SeriesMetadataId") - .HasColumnType("INTEGER"); - - b.Property("PersonId") - .HasColumnType("INTEGER"); - - b.Property("Role") - .HasColumnType("INTEGER"); - - b.HasKey("SeriesMetadataId", "PersonId", "Role"); - - b.HasIndex("PersonId"); - - b.ToTable("SeriesMetadataPeople"); - }); - modelBuilder.Entity("API.Entities.ServerSetting", b => { b.Property("Key") @@ -2409,6 +2834,33 @@ namespace API.Data.Migrations b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + modelBuilder.Entity("API.Entities.AppUserCollection", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2526,6 +2978,17 @@ namespace API.Data.Migrations b.Navigation("Series"); }); + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.AppUserRole", b => { b.HasOne("API.Entities.AppRole", "Role") @@ -2630,25 +3093,6 @@ namespace API.Data.Migrations b.Navigation("Volume"); }); - modelBuilder.Entity("API.Entities.ChapterPeople", b => - { - b.HasOne("API.Entities.Chapter", "Chapter") - .WithMany("People") - .HasForeignKey("ChapterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("API.Entities.Person", "Person") - .WithMany("ChapterPeople") - .HasForeignKey("PersonId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Chapter"); - - b.Navigation("Person"); - }); - modelBuilder.Entity("API.Entities.Device", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2660,6 +3104,17 @@ namespace API.Data.Migrations b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.HasOne("API.Entities.Library", "Library") @@ -2704,6 +3159,20 @@ namespace API.Data.Migrations b.Navigation("Chapter"); }); + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => { b.HasOne("API.Entities.Series", "Series") @@ -2756,6 +3225,66 @@ namespace API.Data.Migrations b.Navigation("TargetSeries"); }); + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2876,25 +3405,6 @@ namespace API.Data.Migrations b.Navigation("Library"); }); - modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => - { - b.HasOne("API.Entities.Person", "Person") - .WithMany("SeriesMetadataPeople") - .HasForeignKey("PersonId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") - .WithMany("People") - .HasForeignKey("SeriesMetadataId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Person"); - - b.Navigation("SeriesMetadata"); - }); - modelBuilder.Entity("API.Entities.Volume", b => { b.HasOne("API.Entities.Series", "Series") @@ -3101,6 +3611,8 @@ namespace API.Data.Migrations { b.Navigation("Bookmarks"); + b.Navigation("ChapterRatings"); + b.Navigation("Collections"); b.Navigation("DashboardStreams"); @@ -3115,6 +3627,8 @@ namespace API.Data.Migrations b.Navigation("ReadingLists"); + b.Navigation("ReadingProfiles"); + b.Navigation("ScrobbleHolds"); b.Navigation("SideNavStreams"); @@ -3132,10 +3646,16 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Chapter", b => { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + b.Navigation("Files"); b.Navigation("People"); + b.Navigation("Ratings"); + b.Navigation("UserProgress"); }); @@ -3155,8 +3675,15 @@ namespace API.Data.Migrations b.Navigation("People"); }); - modelBuilder.Entity("API.Entities.Person", b => + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + b.Navigation("ChapterPeople"); b.Navigation("SeriesMetadataPeople"); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 3b065f2e0..a672259ad 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -16,9 +16,11 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; #nullable enable + public interface IAppUserProgressRepository { void Update(AppUserProgress userProgress); + void Remove(AppUserProgress userProgress); Task CleanupAbandonedChapters(); Task UserHasProgress(LibraryType libraryType, int userId); Task GetUserProgressAsync(int chapterId, int userId); @@ -40,7 +42,7 @@ public interface IAppUserProgressRepository Task UpdateAllProgressThatAreMoreThanChapterPages(); Task> GetUserProgressForChapter(int chapterId, int userId = 0); } -#nullable disable + public class AppUserProgressRepository : IAppUserProgressRepository { private readonly DataContext _context; @@ -57,6 +59,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository _context.Entry(userProgress).State = EntityState.Modified; } + public void Remove(AppUserProgress userProgress) + { + _context.Remove(userProgress); + } + /// /// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well. /// @@ -186,6 +193,7 @@ public class AppUserProgressRepository : IAppUserProgressRepository .Where(p => p.chapter.MaxNumber != Parser.SpecialVolumeNumber) .Select(p => p.chapter.Volume.MaxNumber) .ToListAsync(); + return list.Count == 0 ? 0 : list.DefaultIfEmpty().Max(); } diff --git a/API/Data/Repositories/AppUserReadingProfileRepository.cs b/API/Data/Repositories/AppUserReadingProfileRepository.cs new file mode 100644 index 000000000..11b97f21a --- /dev/null +++ b/API/Data/Repositories/AppUserReadingProfileRepository.cs @@ -0,0 +1,112 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Extensions.QueryExtensions; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + + +public interface IAppUserReadingProfileRepository +{ + + /// + /// Return the given profile if it belongs the user + /// + /// + /// + /// + Task GetUserProfile(int userId, int profileId); + /// + /// Returns all reading profiles for the user + /// + /// + /// + Task> GetProfilesForUser(int userId, bool skipImplicit = false); + /// + /// Returns all reading profiles for the user + /// + /// + /// + Task> GetProfilesDtoForUser(int userId, bool skipImplicit = false); + /// + /// Is there a user reading profile with this name (normalized) + /// + /// + /// + /// + Task IsProfileNameInUse(int userId, string name); + + void Add(AppUserReadingProfile readingProfile); + void Update(AppUserReadingProfile readingProfile); + void Remove(AppUserReadingProfile readingProfile); + void RemoveRange(IEnumerable readingProfiles); +} + +public class AppUserReadingProfileRepository(DataContext context, IMapper mapper): IAppUserReadingProfileRepository +{ + public async Task GetUserProfile(int userId, int profileId) + { + return await context.AppUserReadingProfiles + .Where(rp => rp.AppUserId == userId && rp.Id == profileId) + .FirstOrDefaultAsync(); + } + + public async Task> GetProfilesForUser(int userId, bool skipImplicit = false) + { + return await context.AppUserReadingProfiles + .Where(rp => rp.AppUserId == userId) + .WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit) + .ToListAsync(); + } + + /// + /// Returns all Reading Profiles for the User + /// + /// + /// + public async Task> GetProfilesDtoForUser(int userId, bool skipImplicit = false) + { + return await context.AppUserReadingProfiles + .Where(rp => rp.AppUserId == userId) + .WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit) + .ProjectTo(mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task IsProfileNameInUse(int userId, string name) + { + var normalizedName = name.ToNormalized(); + + return await context.AppUserReadingProfiles + .Where(rp => rp.NormalizedName == normalizedName && rp.AppUserId == userId) + .AnyAsync(); + } + + public void Add(AppUserReadingProfile readingProfile) + { + context.AppUserReadingProfiles.Add(readingProfile); + } + + public void Update(AppUserReadingProfile readingProfile) + { + context.AppUserReadingProfiles.Update(readingProfile).State = EntityState.Modified; + } + + public void Remove(AppUserReadingProfile readingProfile) + { + context.AppUserReadingProfiles.Remove(readingProfile); + } + + public void RemoveRange(IEnumerable readingProfiles) + { + context.AppUserReadingProfiles.RemoveRange(readingProfiles); + } +} diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 52ded9e94..27d21df74 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -5,8 +5,10 @@ using System.Threading.Tasks; using API.DTOs; using API.DTOs.Metadata; using API.DTOs.Reader; +using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Extensions.QueryExtensions; using AutoMapper; @@ -24,7 +26,9 @@ public enum ChapterIncludes Files = 4, People = 8, Genres = 16, - Tags = 32 + Tags = 32, + ExternalReviews = 1 << 6, + ExternalRatings = 1 << 7 } public interface IChapterRepository @@ -47,6 +51,12 @@ public interface IChapterRepository Task> GetCoverImagesForLockedChaptersAsync(); Task AddChapterModifiers(int userId, ChapterDto chapter); IEnumerable GetChaptersForSeries(int seriesId); + Task> GetAllChaptersForSeries(int seriesId); + Task GetAverageUserRating(int chapterId, int userId); + Task> GetExternalChapterReviewDtos(int chapterId); + Task> GetExternalChapterReview(int chapterId); + Task> GetExternalChapterRatingDtos(int chapterId); + Task> GetExternalChapterRatings(int chapterId); } public class ChapterRepository : IChapterRepository { @@ -298,4 +308,66 @@ public class ChapterRepository : IChapterRepository .Include(c => c.Volume) .AsEnumerable(); } + + public async Task> GetAllChaptersForSeries(int seriesId) + { + return await _context.Chapter + .Where(c => c.Volume.SeriesId == seriesId) + .OrderBy(c => c.SortOrder) + .Include(c => c.Volume) + .Include(c => c.People) + .ThenInclude(cp => cp.Person) + .ToListAsync(); + } + + public async Task GetAverageUserRating(int chapterId, int userId) + { + // If there is 0 or 1 rating and that rating is you, return 0 back + var countOfRatingsThatAreUser = await _context.AppUserChapterRating + .Where(r => r.ChapterId == chapterId && r.HasBeenRated) + .CountAsync(u => u.AppUserId == userId); + if (countOfRatingsThatAreUser == 1) + { + return 0; + } + var avg = (await _context.AppUserChapterRating + .Where(r => r.ChapterId == chapterId && r.HasBeenRated) + .AverageAsync(r => (int?) r.Rating)); + return avg.HasValue ? (int) (avg.Value * 20) : 0; + } + + public async Task> GetExternalChapterReviewDtos(int chapterId) + { + return await _context.Chapter + .Where(c => c.Id == chapterId) + .SelectMany(c => c.ExternalReviews) + // Don't use ProjectTo, it fails to map int to float (??) + .Select(r => _mapper.Map(r)) + .ToListAsync(); + } + + public async Task> GetExternalChapterReview(int chapterId) + { + return await _context.Chapter + .Where(c => c.Id == chapterId) + .SelectMany(c => c.ExternalReviews) + .ToListAsync(); + } + + public async Task> GetExternalChapterRatingDtos(int chapterId) + { + return await _context.Chapter + .Where(c => c.Id == chapterId) + .SelectMany(c => c.ExternalRatings) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetExternalChapterRatings(int chapterId) + { + return await _context.Chapter + .Where(c => c.Id == chapterId) + .SelectMany(c => c.ExternalRatings) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/CoverDbRepository.cs b/API/Data/Repositories/CoverDbRepository.cs index 3563f9357..ed13493ab 100644 --- a/API/Data/Repositories/CoverDbRepository.cs +++ b/API/Data/Repositories/CoverDbRepository.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using API.DTOs.CoverDb; using API.Entities; +using API.Entities.Person; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; diff --git a/API/Data/Repositories/EmailHistoryRepository.cs b/API/Data/Repositories/EmailHistoryRepository.cs new file mode 100644 index 000000000..e5ed1377a --- /dev/null +++ b/API/Data/Repositories/EmailHistoryRepository.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Email; +using API.Entities; +using API.Helpers; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IEmailHistoryRepository +{ + Task> GetEmailDtos(UserParams userParams); +} + +public class EmailHistoryRepository : IEmailHistoryRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public EmailHistoryRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + + public async Task> GetEmailDtos(UserParams userParams) + { + return await _context.EmailHistory + .OrderByDescending(h => h.SendDate) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } +} diff --git a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs index 2fabce824..377344a3c 100644 --- a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs +++ b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using API.Constants; using API.DTOs; +using API.DTOs.KavitaPlus.Manage; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -31,14 +32,12 @@ public interface IExternalSeriesMetadataRepository void Remove(IEnumerable? recommendations); void Remove(ExternalSeriesMetadata metadata); Task GetExternalSeriesMetadata(int seriesId); - Task ExternalSeriesMetadataNeedsRefresh(int seriesId); - Task GetSeriesDetailPlusDto(int seriesId); + Task NeedsDataRefresh(int seriesId); + Task GetSeriesDetailPlusDto(int seriesId); Task LinkRecommendationsToSeries(Series series); - Task LinkRecommendationsToSeries(int seriesId); Task IsBlacklistedSeries(int seriesId); - Task CreateBlacklistedSeries(int seriesId, bool saveChanges = true); - Task RemoveFromBlacklist(int seriesId); - Task> GetAllSeriesIdsWithoutMetadata(int limit); + Task> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false); + Task> GetAllSeries(ManageMatchFilterDto filter); } public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepository @@ -107,16 +106,19 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .FirstOrDefaultAsync(); } - public async Task ExternalSeriesMetadataNeedsRefresh(int seriesId) + public async Task NeedsDataRefresh(int seriesId) { + // TODO: Add unit test var row = await _context.ExternalSeriesMetadata .Where(s => s.SeriesId == seriesId) .FirstOrDefaultAsync(); + return row == null || row.ValidUntilUtc <= DateTime.UtcNow; } - public async Task GetSeriesDetailPlusDto(int seriesId) + public async Task GetSeriesDetailPlusDto(int seriesId) { + // TODO: Add unit test var seriesDetailDto = await _context.ExternalSeriesMetadata .Where(m => m.SeriesId == seriesId) .Include(m => m.ExternalRatings) @@ -145,7 +147,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - IEnumerable reviews = new List(); + IEnumerable reviews = []; if (seriesDetailDto.ExternalReviews != null && seriesDetailDto.ExternalReviews.Any()) { reviews = seriesDetailDto.ExternalReviews @@ -158,8 +160,8 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .OrderByDescending(r => r.Score); } - IEnumerable ratings = new List(); - if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Any()) + IEnumerable ratings = []; + if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Count != 0) { ratings = seriesDetailDto.ExternalRatings .Select(r => _mapper.Map(r)); @@ -180,13 +182,6 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor return seriesDetailPlusDto; } - public async Task LinkRecommendationsToSeries(int seriesId) - { - var series = await _context.Series.Where(s => s.Id == seriesId).AsNoTracking().SingleOrDefaultAsync(); - if (series == null) return; - await LinkRecommendationsToSeries(series); - } - /// /// Searches Recommendations without a SeriesId on record and attempts to link based on Series Name/Localized Name /// @@ -210,55 +205,39 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor public Task IsBlacklistedSeries(int seriesId) { - return _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId); + return _context.Series + .Where(s => s.Id == seriesId) + .Select(s => s.IsBlacklisted) + .FirstOrDefaultAsync(); } - /// - /// Creates a new instance against SeriesId and Saves to the DB - /// - /// - /// - public async Task CreateBlacklistedSeries(int seriesId, bool saveChanges = true) - { - if (seriesId <= 0 || await _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId)) return; - await _context.SeriesBlacklist.AddAsync(new SeriesBlacklist() - { - SeriesId = seriesId - }); - if (saveChanges) - { - await _context.SaveChangesAsync(); - } - } - - /// - /// Removes the Series from Blacklist and Saves to the DB - /// - /// - public async Task RemoveFromBlacklist(int seriesId) - { - var seriesBlacklist = await _context.SeriesBlacklist.FirstOrDefaultAsync(sb => sb.SeriesId == seriesId); - - if (seriesBlacklist != null) - { - // Remove the SeriesBlacklist entity from the context - _context.SeriesBlacklist.Remove(seriesBlacklist); - - // Save the changes to the database - await _context.SaveChangesAsync(); - } - } - - public async Task> GetAllSeriesIdsWithoutMetadata(int limit) + public async Task> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false) { return await _context.Series .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) - .Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow) + .Where(s => s.Library.AllowMetadataMatching) + .WhereIf(includeStaleData, s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow) + .Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.AniListId == 0) + .Where(s => !s.IsBlacklisted && !s.DontMatch) .OrderByDescending(s => s.Library.Type) .ThenBy(s => s.NormalizedName) .Select(s => s.Id) .Take(limit) .ToListAsync(); } + + public async Task> GetAllSeries(ManageMatchFilterDto filter) + { + return await _context.Series + .Include(s => s.Library) + .Include(s => s.ExternalSeriesMetadata) + .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) + .Where(s => s.Library.AllowMetadataMatching) + .WhereIf(filter.LibraryType >= 0, s => s.Library.Type == (LibraryType) filter.LibraryType) + .FilterMatchState(filter.MatchStateOption) + .OrderBy(s => s.NormalizedName) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index 7492ba5dd..d3baa4de6 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -3,15 +3,18 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IGenreRepository { @@ -26,6 +29,7 @@ public interface IGenreRepository Task GetRandomGenre(); Task GetGenreById(int id); Task> GetAllGenresNotInListAsync(ICollection genreNames); + Task> GetBrowseableGenre(int userId, UserParams userParams); } public class GenreRepository : IGenreRepository @@ -110,7 +114,7 @@ public class GenreRepository : IGenreRepository /// /// Returns a set of Genre tags for a set of library Ids. - /// UserId will restrict returned Genres based on user's age restriction and library access. + /// AppUserId will restrict returned Genres based on user's age restriction and library access. /// /// /// @@ -164,4 +168,38 @@ public class GenreRepository : IGenreRepository // Return the original non-normalized genres for the missing ones return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); } + + public async Task> GetBrowseableGenre(int userId, UserParams userParams) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = await _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + + var query = _context.Genre + .RestrictAgainstAgeRestriction(ageRating) + .WhereIf(allLibrariesCount != userLibs.Count, + genre => genre.Chapters.Any(cp => seriesIds.Contains(cp.Volume.SeriesId)) || + genre.SeriesMetadatas.Any(sm => seriesIds.Contains(sm.SeriesId))) + .Select(g => new BrowseGenreDto + { + Id = g.Id, + Title = g.Title, + SeriesCount = g.SeriesMetadatas + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + ChapterCount = g.Chapters + .Where(cp => allLibrariesCount == userLibs.Count || seriesIds.Contains(cp.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + }) + .OrderBy(g => g.Title); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } } diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 605656d91..78022fa9a 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -18,6 +18,7 @@ using Kavita.Common.Extensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum LibraryIncludes @@ -260,7 +261,7 @@ public class LibraryRepository : ILibraryRepository public async Task> GetAllLanguagesForLibrariesAsync(List? libraryIds) { var ret = await _context.Series - .WhereIf(libraryIds is {Count: > 0} , s => libraryIds.Contains(s.LibraryId)) + .WhereIf(libraryIds is {Count: > 0} , s => libraryIds!.Contains(s.LibraryId)) .Select(s => s.Metadata.Language) .AsSplitQuery() .AsNoTracking() diff --git a/API/Data/Repositories/MangaFileRepository.cs b/API/Data/Repositories/MangaFileRepository.cs index debd52199..89c6bb418 100644 --- a/API/Data/Repositories/MangaFileRepository.cs +++ b/API/Data/Repositories/MangaFileRepository.cs @@ -5,11 +5,13 @@ using API.Entities; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IMangaFileRepository { void Update(MangaFile file); Task> GetAllWithMissingExtension(); + Task GetByKoreaderHash(string hash); } public class MangaFileRepository : IMangaFileRepository @@ -32,4 +34,13 @@ public class MangaFileRepository : IMangaFileRepository .Where(f => string.IsNullOrEmpty(f.Extension)) .ToListAsync(); } + + public async Task GetByKoreaderHash(string hash) + { + if (string.IsNullOrEmpty(hash)) return null; + + return await _context.MangaFile + .FirstOrDefaultAsync(f => f.KoreaderHash != null && + f.KoreaderHash.Equals(hash.ToUpper())); + } } diff --git a/API/Data/Repositories/MediaErrorRepository.cs b/API/Data/Repositories/MediaErrorRepository.cs index c2e932d32..40501768e 100644 --- a/API/Data/Repositories/MediaErrorRepository.cs +++ b/API/Data/Repositories/MediaErrorRepository.cs @@ -9,15 +9,18 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IMediaErrorRepository { void Attach(MediaError error); void Remove(MediaError error); + void Remove(IList errors); Task Find(string filename); IEnumerable GetAllErrorDtosAsync(); Task ExistsAsync(MediaError error); Task DeleteAll(); + Task> GetAllErrorsAsync(IList comments); } public class MediaErrorRepository : IMediaErrorRepository @@ -43,6 +46,11 @@ public class MediaErrorRepository : IMediaErrorRepository _context.MediaError.Remove(error); } + public void Remove(IList errors) + { + _context.MediaError.RemoveRange(errors); + } + public Task Find(string filename) { return _context.MediaError.Where(e => e.FilePath == filename).SingleOrDefaultAsync(); @@ -70,4 +78,11 @@ public class MediaErrorRepository : IMediaErrorRepository _context.MediaError.RemoveRange(await _context.MediaError.ToListAsync()); await _context.SaveChangesAsync(); } + + public Task> GetAllErrorsAsync(IList comments) + { + return _context.MediaError + .Where(m => comments.Contains(m.Comment)) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index ee911ccba..76ae94735 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -1,19 +1,37 @@ -using System.Collections; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data.Misc; using API.DTOs; -using API.Entities; +using API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; +using API.DTOs.Person; using API.Entities.Enums; +using API.Entities.Person; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Extensions.QueryExtensions.Filtering; using API.Helpers; +using API.Helpers.Converters; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable + +[Flags] +public enum PersonIncludes +{ + None = 1 << 0, + Aliases = 1 << 1, + ChapterPeople = 1 << 2, + SeriesPeople = 1 << 3, + + All = Aliases | ChapterPeople | SeriesPeople, +} public interface IPersonRepository { @@ -24,25 +42,41 @@ public interface IPersonRepository void Remove(SeriesMetadataPeople person); void Update(Person person); - Task> GetAllPeople(); - Task> GetAllPersonDtosAsync(int userId); - Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); + Task> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases); + Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None); + Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None); Task RemoveAllPeopleNoLongerAssociated(); - Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null); - Task GetCountAsync(); + Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null, PersonIncludes includes = PersonIncludes.None); - Task GetCoverImageAsync(int personId); + Task GetCoverImageAsync(int personId); Task GetCoverImageByNameAsync(string name); Task> GetRolesForPersonByName(int personId, int userId); - Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams); - Task GetPersonById(int personId); - Task GetPersonDtoByName(string name, int userId); - Task GetPersonByName(string name); + Task> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams); + Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None); + Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases); + /// + /// Returns a person matched on normalized name or alias + /// + /// + /// + /// + Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases); Task IsNameUnique(string name); - Task> GetSeriesKnownFor(int personId); + Task> GetSeriesKnownFor(int personId, int userId); Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); - Task> GetPeopleByNames(List normalizedNames); + /// + /// Returns all people with a matching name, or alias + /// + /// + /// + /// + Task> GetPeopleByNames(List normalizedNames, PersonIncludes includes = PersonIncludes.Aliases); + Task GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases); + + Task> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases); + + Task AnyAliasExist(string alias); } public class PersonRepository : IPersonRepository @@ -101,7 +135,7 @@ public class PersonRepository : IPersonRepository } - public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null) + public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null, PersonIncludes includes = PersonIncludes.Aliases) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); @@ -115,6 +149,7 @@ public class PersonRepository : IPersonRepository .Where(s => userLibs.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(ageRating) .SelectMany(s => s.Metadata.People.Select(p => p.Person)) + .Includes(includes) .Distinct() .OrderBy(p => p.Name) .AsNoTracking() @@ -123,12 +158,8 @@ public class PersonRepository : IPersonRepository .ToListAsync(); } - public async Task GetCountAsync() - { - return await _context.Person.CountAsync(); - } - public async Task GetCoverImageAsync(int personId) + public async Task GetCoverImageAsync(int personId) { return await _context.Person .Where(c => c.Id == personId) @@ -136,7 +167,7 @@ public class PersonRepository : IPersonRepository .SingleOrDefaultAsync(); } - public async Task GetCoverImageByNameAsync(string name) + public async Task GetCoverImageByNameAsync(string name) { var normalized = name.ToNormalized(); return await _context.Person @@ -148,20 +179,25 @@ public class PersonRepository : IPersonRepository public async Task> GetRolesForPersonByName(int personId, int userId) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); // Query roles from ChapterPeople var chapterRoles = await _context.Person .Where(p => p.Id == personId) + .SelectMany(p => p.ChapterPeople) .RestrictAgainstAgeRestriction(ageRating) - .SelectMany(p => p.ChapterPeople.Select(cp => cp.Role)) + .RestrictByLibrary(userLibs) + .Select(cp => cp.Role) .Distinct() .ToListAsync(); // Query roles from SeriesMetadataPeople var seriesRoles = await _context.Person .Where(p => p.Id == personId) + .SelectMany(p => p.SeriesMetadataPeople) .RestrictAgainstAgeRestriction(ageRating) - .SelectMany(p => p.SeriesMetadataPeople.Select(smp => smp.Role)) + .RestrictByLibrary(userLibs) + .Select(smp => smp.Role) .Distinct() .ToListAsync(); @@ -169,71 +205,142 @@ public class PersonRepository : IPersonRepository return chapterRoles.Union(seriesRoles).Distinct(); } - public async Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams) + public async Task> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams) { - List roles = [PersonRole.Writer, PersonRole.CoverArtist]; var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); - var query = _context.Person - .Where(p => p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))) - .RestrictAgainstAgeRestriction(ageRating) - .Select(p => new BrowsePersonDto - { - Id = p.Id, - Name = p.Name, - Description = p.Description, - CoverImage = p.CoverImage, - SeriesCount = p.SeriesMetadataPeople - .Where(smp => roles.Contains(smp.Role)) - .Select(smp => smp.SeriesMetadata.SeriesId) - .Distinct() - .Count(), - IssueCount = p.ChapterPeople - .Where(cp => roles.Contains(cp.Role)) - .Select(cp => cp.Chapter.Id) - .Distinct() - .Count() - }) - .OrderBy(p => p.Name); + var query = await CreateFilteredPersonQueryable(userId, filter, ageRating); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } - public async Task GetPersonById(int personId) + private async Task> CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating) + { + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = await _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + + var query = _context.Person.AsNoTracking(); + + // Apply filtering based on statements + query = BuildPersonFilterQuery(userId, filter, query); + + // Apply restrictions + query = query.RestrictAgainstAgeRestriction(ageRating) + .WhereIf(allLibrariesCount != userLibs.Count, + person => person.ChapterPeople.Any(cp => seriesIds.Contains(cp.Chapter.Volume.SeriesId)) || + person.SeriesMetadataPeople.Any(smp => seriesIds.Contains(smp.SeriesMetadata.SeriesId))); + + // Apply sorting and limiting + var sortedQuery = query.SortBy(filter.SortOptions); + + var limitedQuery = ApplyPersonLimit(sortedQuery, filter.LimitTo); + + return limitedQuery.Select(p => new BrowsePersonDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + CoverImage = p.CoverImage, + SeriesCount = p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata) + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + ChapterCount = p.ChapterPeople + .Select(chp => chp.Chapter) + .Where(ch => allLibrariesCount == userLibs.Count || seriesIds.Contains(ch.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + }); + } + + private static IQueryable BuildPersonFilterQuery(int userId, BrowsePersonFilterDto filterDto, IQueryable query) + { + if (filterDto.Statements == null || filterDto.Statements.Count == 0) return query; + + var queries = filterDto.Statements + .Select(statement => BuildPersonFilterGroup(userId, statement, query)) + .ToList(); + + return filterDto.Combination == FilterCombination.And + ? queries.Aggregate((q1, q2) => q1.Intersect(q2)) + : queries.Aggregate((q1, q2) => q1.Union(q2)); + } + + private static IQueryable BuildPersonFilterGroup(int userId, PersonFilterStatementDto statement, IQueryable query) + { + var value = PersonFilterFieldValueConverter.ConvertValue(statement.Field, statement.Value); + + return statement.Field switch + { + PersonFilterField.Name => query.HasPersonName(true, statement.Comparison, (string)value), + PersonFilterField.Role => query.HasPersonRole(true, statement.Comparison, (IList)value), + PersonFilterField.SeriesCount => query.HasPersonSeriesCount(true, statement.Comparison, (int)value), + PersonFilterField.ChapterCount => query.HasPersonChapterCount(true, statement.Comparison, (int)value), + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") + }; + } + + private static IQueryable ApplyPersonLimit(IQueryable query, int limit) + { + return limit <= 0 ? query : query.Take(limit); + } + + public async Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None) { return await _context.Person.Where(p => p.Id == personId) + .Includes(includes) .FirstOrDefaultAsync(); } - public async Task GetPersonDtoByName(string name, int userId) + public async Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases) { var normalized = name.ToNormalized(); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Where(p => p.NormalizedName == normalized) + .Includes(includes) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) .ProjectTo(_mapper.ConfigurationProvider) .FirstOrDefaultAsync(); } - public async Task GetPersonByName(string name) + public Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases) { - return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized()); + var normalized = name.ToNormalized(); + return _context.Person + .Includes(includes) + .Where(p => p.NormalizedName == normalized || p.Aliases.Any(pa => pa.NormalizedAlias == normalized)) + .FirstOrDefaultAsync(); } public async Task IsNameUnique(string name) { - return !(await _context.Person.AnyAsync(p => p.Name == name)); + // Should this use Normalized to check? + return !(await _context.Person + .Includes(PersonIncludes.Aliases) + .AnyAsync(p => p.Name == name || p.Aliases.Any(pa => pa.Alias == name))); } - public async Task> GetSeriesKnownFor(int personId) + public async Task> GetSeriesKnownFor(int personId, int userId) { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + return await _context.Person .Where(p => p.Id == personId) .SelectMany(p => p.SeriesMetadataPeople) .Select(smp => smp.SeriesMetadata) .Select(sm => sm.Series) + .RestrictAgainstAgeRestriction(ageRating) + .Where(s => userLibs.Contains(s.LibraryId)) .Distinct() .OrderByDescending(s => s.ExternalSeriesMetadata.AverageExternalRating) .Take(20) @@ -244,51 +351,88 @@ public class PersonRepository : IPersonRepository public async Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.ChapterPeople .Where(cp => cp.PersonId == personId && cp.Role == role) .Select(cp => cp.Chapter) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) .OrderBy(ch => ch.SortOrder) .Take(20) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - public async Task> GetPeopleByNames(List normalizedNames) + public async Task> GetPeopleByNames(List normalizedNames, PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person - .Where(p => normalizedNames.Contains(p.NormalizedName)) + .Includes(includes) + .Where(p => normalizedNames.Contains(p.NormalizedName) || p.Aliases.Any(pa => normalizedNames.Contains(pa.NormalizedAlias))) .OrderBy(p => p.Name) .ToListAsync(); } - public async Task> GetAllPeople() + public async Task GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person - .OrderBy(p => p.Name) - .ToListAsync(); + .Where(p => p.AniListId == aniListId) + .Includes(includes) + .FirstOrDefaultAsync(); } - public async Task> GetAllPersonDtosAsync(int userId) + public async Task> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases) { - var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + searchQuery = searchQuery.ToNormalized(); return await _context.Person - .OrderBy(p => p.Name) - .RestrictAgainstAgeRestriction(ageRating) + .Includes(includes) + .Where(p => EF.Functions.Like(p.NormalizedName, $"%{searchQuery}%") + || p.Aliases.Any(pa => EF.Functions.Like(pa.NormalizedAlias, $"%{searchQuery}%"))) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role) + + public async Task AnyAliasExist(string alias) + { + return await _context.PersonAlias.AnyAsync(pa => pa.NormalizedAlias == alias.ToNormalized()); + } + + + public async Task> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases) + { + return await _context.Person + .Includes(includes) + .OrderBy(p => p.Name) + .ToListAsync(); + } + + public async Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); + + return await _context.Person + .Includes(includes) + .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) + .OrderBy(p => p.Name) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters - .OrderBy(p => p.Name) + .Includes(includes) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) + .OrderBy(p => p.Name) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index b2cc5d007..6992b2950 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -2,7 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.DTOs; +using API.Data.Misc; +using API.DTOs.Person; using API.DTOs.ReadingLists; using API.Entities; using API.Entities.Enums; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum ReadingListIncludes @@ -47,11 +49,15 @@ public interface IReadingListRepository Task> GetRandomCoverImagesAsync(int readingListId); Task> GetAllCoverImagesAsync(); Task ReadingListExists(string name); - IEnumerable GetReadingListCharactersAsync(int readingListId); + Task ReadingListExistsForUser(string name, int userId); + IEnumerable GetReadingListPeopleAsync(int readingListId, PersonRole role); + Task GetReadingListAllPeopleAsync(int readingListId); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task RemoveReadingListsWithoutSeries(); Task GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items); + Task> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items); + Task GetReadingListInfoAsync(int readingListId); } public class ReadingListRepository : IReadingListRepository @@ -104,6 +110,7 @@ public class ReadingListRepository : IReadingListRepository .SelectMany(r => r.Items.Select(ri => ri.Chapter.CoverImage)) .Where(t => !string.IsNullOrEmpty(t)) .ToListAsync(); + return data .OrderBy(_ => random.Next()) .Take(4) @@ -118,12 +125,19 @@ public class ReadingListRepository : IReadingListRepository .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); } - public IEnumerable GetReadingListCharactersAsync(int readingListId) + public async Task ReadingListExistsForUser(string name, int userId) + { + var normalized = name.ToNormalized(); + return await _context.ReadingList + .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized) && x.AppUserId == userId); + } + + public IEnumerable GetReadingListPeopleAsync(int readingListId, PersonRole role) { return _context.ReadingListItem .Where(item => item.ReadingListId == readingListId) .SelectMany(item => item.Chapter.People) - .Where(p => p.Role == PersonRole.Character) + .Where(p => p.Role == role) .OrderBy(p => p.Person.NormalizedName) .Select(p => p.Person) .Distinct() @@ -131,6 +145,77 @@ public class ReadingListRepository : IReadingListRepository .AsEnumerable(); } + public async Task GetReadingListAllPeopleAsync(int readingListId) + { + var allPeople = await _context.ReadingListItem + .Where(item => item.ReadingListId == readingListId) + .SelectMany(item => item.Chapter.People) + .OrderBy(p => p.Person.NormalizedName) + .Select(p => new + { + Role = p.Role, + Person = _mapper.Map(p.Person) + }) + .Distinct() + .ToListAsync(); + + // Create the ReadingListCast object + var cast = new ReadingListCast(); + + // Group people by role and populate the appropriate collections + foreach (var personGroup in allPeople.GroupBy(p => p.Role)) + { + var people = personGroup.Select(pg => pg.Person).ToList(); + + switch (personGroup.Key) + { + case PersonRole.Writer: + cast.Writers = people; + break; + case PersonRole.CoverArtist: + cast.CoverArtists = people; + break; + case PersonRole.Publisher: + cast.Publishers = people; + break; + case PersonRole.Character: + cast.Characters = people; + break; + case PersonRole.Penciller: + cast.Pencillers = people; + break; + case PersonRole.Inker: + cast.Inkers = people; + break; + case PersonRole.Imprint: + cast.Imprints = people; + break; + case PersonRole.Colorist: + cast.Colorists = people; + break; + case PersonRole.Letterer: + cast.Letterers = people; + break; + case PersonRole.Editor: + cast.Editors = people; + break; + case PersonRole.Translator: + cast.Translators = people; + break; + case PersonRole.Team: + cast.Teams = people; + break; + case PersonRole.Location: + cast.Locations = people; + break; + case PersonRole.Other: + break; + } + } + + return cast; + } + public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) { var extension = encodeFormat.GetExtension(); @@ -169,7 +254,41 @@ public class ReadingListRepository : IReadingListRepository .AsSplitQuery() .ToListAsync(); } + public async Task> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items) + { + return await _context.ReadingList + .Where(rl => rl.Items.Any(rli => rli.SeriesId == seriesId)) + .Includes(includes) + .AsSplitQuery() + .ToListAsync(); + } + /// + /// Returns a Partial ReadingListInfoDto. The HourEstimate needs to be calculated outside the repo + /// + /// + /// + public async Task GetReadingListInfoAsync(int readingListId) + { + // Get sum of these across all ReadingListItems: long wordCount, int pageCount, bool isEpub (assume false if any ReadingListeItem.Series.Format is non-epub) + var readingList = await _context.ReadingList + .Where(rl => rl.Id == readingListId) + .Include(rl => rl.Items) + .ThenInclude(item => item.Series) + .Include(rl => rl.Items) + .ThenInclude(item => item.Volume) + .Include(rl => rl.Items) + .ThenInclude(item => item.Chapter) + .Select(rl => new ReadingListInfoDto() + { + WordCount = rl.Items.Sum(item => item.Chapter.WordCount), + Pages = rl.Items.Sum(item => item.Chapter.Pages), + IsAllEpub = rl.Items.All(item => item.Series.Format == MangaFormat.Epub), + }) + .FirstOrDefaultAsync(); + + return readingList; + } public void Remove(ReadingListItem item) @@ -185,10 +304,11 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams, bool sortByLastModified = true) { - var userAgeRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) - .Where(l => l.AgeRating >= userAgeRating); + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()); + query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.NormalizedTitle); var finalQuery = query.ProjectTo(_mapper.ConfigurationProvider) @@ -199,8 +319,10 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, bool includePromoted) { + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()) .Where(l => l.Items.Any(i => i.SeriesId == seriesId)) .AsSplitQuery() .OrderBy(l => l.Title) @@ -212,8 +334,10 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListDtosForChapterAndUserAsync(int userId, int chapterId, bool includePromoted) { + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()) .Where(l => l.Items.Any(i => i.ChapterId == chapterId)) .AsSplitQuery() .OrderBy(l => l.Title) @@ -337,8 +461,10 @@ public class ReadingListRepository : IReadingListRepository public async Task GetReadingListDtoByIdAsync(int readingListId, int userId) { + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); return await _context.ReadingList .Where(r => r.Id == readingListId && (r.AppUserId == userId || r.Promoted)) + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()) .ProjectTo(_mapper.ConfigurationProvider) .SingleOrDefaultAsync(); } diff --git a/API/Data/Repositories/ScrobbleEventRepository.cs b/API/Data/Repositories/ScrobbleEventRepository.cs index 7d3567831..144a3b88e 100644 --- a/API/Data/Repositories/ScrobbleEventRepository.cs +++ b/API/Data/Repositories/ScrobbleEventRepository.cs @@ -12,6 +12,7 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IScrobbleRepository { @@ -19,16 +20,36 @@ public interface IScrobbleRepository void Attach(ScrobbleError error); void Remove(ScrobbleEvent evt); void Remove(IEnumerable events); + void Remove(IEnumerable errors); void Update(ScrobbleEvent evt); Task> GetByEvent(ScrobbleEventType type, bool isProcessed = false); Task> GetProcessedEvents(int daysAgo); Task Exists(int userId, int seriesId, ScrobbleEventType eventType); Task> GetScrobbleErrors(); + Task> GetAllScrobbleErrorsForSeries(int seriesId); Task ClearScrobbleErrors(); Task HasErrorForSeries(int seriesId); - Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType); + /// + /// Get all events for a specific user and type + /// + /// + /// + /// + /// If true, only returned not processed events + /// + Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType, bool isNotProcessed = false); Task> GetUserEventsForSeries(int userId, int seriesId); + /// + /// Return the events with given ids, when belonging to the passed user + /// + /// + /// + /// + Task> GetUserEvents(int userId, IList scrobbleEventIds); Task> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination); + Task> GetAllEventsForSeries(int seriesId); + Task> GetAllEventsWithSeriesIds(IEnumerable seriesIds); + Task> GetEvents(); } /// @@ -65,6 +86,11 @@ public class ScrobbleRepository : IScrobbleRepository _context.ScrobbleEvent.RemoveRange(events); } + public void Remove(IEnumerable errors) + { + _context.ScrobbleError.RemoveRange(errors); + } + public void Update(ScrobbleEvent evt) { _context.Entry(evt).State = EntityState.Modified; @@ -78,6 +104,7 @@ public class ScrobbleRepository : IScrobbleRepository .Include(s => s.Series) .ThenInclude(s => s.Metadata) .Include(s => s.AppUser) + .ThenInclude(u => u.UserPreferences) .Where(s => s.ScrobbleEventType == type) .Where(s => s.IsProcessed == isProcessed) .AsSplitQuery() @@ -88,6 +115,11 @@ public class ScrobbleRepository : IScrobbleRepository .ToListAsync(); } + /// + /// Returns all processed events that were processed 7 or more days ago + /// + /// + /// public async Task> GetProcessedEvents(int daysAgo) { var date = DateTime.UtcNow.Subtract(TimeSpan.FromDays(daysAgo)); @@ -111,6 +143,13 @@ public class ScrobbleRepository : IScrobbleRepository .ToListAsync(); } + public async Task> GetAllScrobbleErrorsForSeries(int seriesId) + { + return await _context.ScrobbleError + .Where(e => e.SeriesId == seriesId) + .ToListAsync(); + } + public async Task ClearScrobbleErrors() { _context.ScrobbleError.RemoveRange(_context.ScrobbleError); @@ -122,35 +161,66 @@ public class ScrobbleRepository : IScrobbleRepository return await _context.ScrobbleError.AnyAsync(n => n.SeriesId == seriesId); } - public async Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType) + public async Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType, bool isNotProcessed = false) { - return await _context.ScrobbleEvent.FirstOrDefaultAsync(e => - e.AppUserId == userId && e.SeriesId == seriesId && e.ScrobbleEventType == eventType); + return await _context.ScrobbleEvent + .Where(e => e.AppUserId == userId && e.SeriesId == seriesId && e.ScrobbleEventType == eventType) + .WhereIf(isNotProcessed, e => !e.IsProcessed) + .OrderBy(e => e.LastModifiedUtc) + .FirstOrDefaultAsync(); } public async Task> GetUserEventsForSeries(int userId, int seriesId) { return await _context.ScrobbleEvent - .Where(e => e.AppUserId == userId && !e.IsProcessed) + .Where(e => e.AppUserId == userId && !e.IsProcessed && e.SeriesId == seriesId) .Include(e => e.Series) .OrderBy(e => e.LastModifiedUtc) .AsSplitQuery() .ToListAsync(); } + public async Task> GetUserEvents(int userId, IList scrobbleEventIds) + { + return await _context.ScrobbleEvent + .Where(e => e.AppUserId == userId && scrobbleEventIds.Contains(e.Id)) + .ToListAsync(); + } + public async Task> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination) { var query = _context.ScrobbleEvent .Where(e => e.AppUserId == userId) .Include(e => e.Series) - .SortBy(filter.Field, filter.IsDescending) .WhereIf(!string.IsNullOrEmpty(filter.Query), s => EF.Functions.Like(s.Series.Name, $"%{filter.Query}%") ) .WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review) + .SortBy(filter.Field, filter.IsDescending) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider); return await PagedList.CreateAsync(query, pagination.PageNumber, pagination.PageSize); } + + public async Task> GetAllEventsForSeries(int seriesId) + { + return await _context.ScrobbleEvent + .Where(e => e.SeriesId == seriesId) + .ToListAsync(); + } + + public async Task> GetAllEventsWithSeriesIds(IEnumerable seriesIds) + { + return await _context.ScrobbleEvent + .Where(e => seriesIds.Contains(e.SeriesId)) + .ToListAsync(); + } + + public async Task> GetEvents() + { + return await _context.ScrobbleEvent + .Include(e => e.AppUser) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 91a186746..0c4b8350a 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -13,8 +13,11 @@ using API.DTOs.CollectionTags; using API.DTOs.Dashboard; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.ReadingLists; +using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.Search; using API.DTOs.SeriesDetail; @@ -38,6 +41,7 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum SeriesIncludes @@ -55,6 +59,8 @@ public enum SeriesIncludes ExternalRatings = 128, ExternalRecommendations = 256, ExternalMetadata = 512, + + ExternalData = ExternalMetadata | ExternalReviews | ExternalRatings | ExternalRecommendations, } /// @@ -74,7 +80,9 @@ public interface ISeriesRepository { void Add(Series series); void Attach(Series series); + void Attach(SeriesRelation relation); void Update(Series series); + void Update(SeriesMetadata seriesMetadata); void Remove(Series series); void Remove(IEnumerable series); void Detach(Series series); @@ -145,7 +153,10 @@ public interface ISeriesRepository Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); - Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId); + + Task GetSeriesByAnyName(IList names, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None); + Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None); public Task> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format); Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); @@ -163,8 +174,9 @@ public interface ISeriesRepository Task RemoveFromOnDeck(int seriesId, int userId); Task ClearOnDeckRemoval(int seriesId, int userId); Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None); - Task GetPlusSeriesDto(int seriesId); + Task GetPlusSeriesDto(int seriesId); Task GetCountAsync(); + Task MatchSeries(ExternalSeriesDetailDto externalSeries); } public class SeriesRepository : ISeriesRepository @@ -193,6 +205,11 @@ public class SeriesRepository : ISeriesRepository _context.Series.Attach(series); } + public void Attach(SeriesRelation relation) + { + _context.SeriesRelation.Attach(relation); + } + public void Attach(ExternalSeriesMetadata metadata) { _context.ExternalSeriesMetadata.Attach(metadata); @@ -203,6 +220,11 @@ public class SeriesRepository : ISeriesRepository _context.Entry(series).State = EntityState.Modified; } + public void Update(SeriesMetadata seriesMetadata) + { + _context.Entry(seriesMetadata).State = EntityState.Modified; + } + public void Remove(Series series) { _context.Series.Remove(series); @@ -440,11 +462,18 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Persons = await _context.SeriesMetadata + // I can't work out how to map people in DB layer + var personIds = await _context.SeriesMetadata .SearchPeople(searchQuery, seriesIds) - .Take(maxRecords) - .OrderBy(t => t.NormalizedName) + .Select(p => p.Id) .Distinct() + .OrderBy(id => id) + .Take(maxRecords) + .ToListAsync(); + + result.Persons = await _context.Person + .Where(p => personIds.Contains(p.Id)) + .OrderBy(p => p.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -460,8 +489,8 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Files = new List(); - result.Chapters = new List(); + result.Files = []; + result.Chapters = (List) []; if (includeChapterAndFiles) @@ -551,7 +580,13 @@ public class SeriesRepository : ISeriesRepository if (!fullSeries) return await query.ToListAsync(); - return await query.Include(s => s.Volumes) + return await query + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.ExternalRatings) + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.ExternalReviews) .Include(s => s.Relations) .Include(s => s.Metadata) @@ -697,25 +732,26 @@ public class SeriesRepository : ISeriesRepository var retSeries = query .ProjectTo(_mapper.ConfigurationProvider) - //.AsSplitQuery() .AsNoTracking(); return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); } - public async Task GetPlusSeriesDto(int seriesId) + public async Task GetPlusSeriesDto(int seriesId) { return await _context.Series .Where(s => s.Id == seriesId) - .Select(series => new PlusSeriesDto() + .Include(s => s.ExternalSeriesMetadata) + .Select(series => new PlusSeriesRequestDto() { - MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), + MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), SeriesName = series.Name, AltSeriesName = series.LocalizedName, AniListId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.AniListWeblinkWebsite), MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.MalWeblinkWebsite), + CbrId = series.ExternalSeriesMetadata.CbrId, GoogleBooksId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.GoogleBooksWeblinkWebsite), MangaDexId = ScrobblingService.ExtractId(series.Metadata.WebLinks, @@ -1060,8 +1096,6 @@ public class SeriesRepository : ISeriesRepository return query.Where(s => false); } - - // First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here query = ApplyLibraryFilter(filter, query); @@ -1262,7 +1296,7 @@ public class SeriesRepository : ISeriesRepository FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId), FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId), FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value), - _ => throw new ArgumentOutOfRangeException() + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") }; } @@ -1723,24 +1757,71 @@ public class SeriesRepository : ISeriesRepository #nullable enable } - public async Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId) + public async Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) { var libraryIds = GetLibraryIdsForUser(userId); var normalizedSeries = seriesName.ToNormalized(); var normalizedLocalized = localizedName.ToNormalized(); - return await _context.Series + var query = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) - .Where(s => formats.Contains(s.Format)) - .Where(s => + .Where(s => formats.Contains(s.Format)); + + if (aniListId.HasValue && aniListId.Value > 0) + { + // If AniList ID is provided, override name checks + query = query.Where(s => s.ExternalSeriesMetadata.AniListId == aniListId.Value); + } + else + { + // Otherwise, use name checks + query = query.Where(s => s.NormalizedName.Equals(normalizedSeries) || s.NormalizedName.Equals(normalizedLocalized) - || s.NormalizedLocalizedName.Equals(normalizedSeries) || (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized)) - || (s.OriginalName != null && s.OriginalName.Equals(seriesName)) - ) + ); + } + + return await query + .Includes(includes) + .FirstOrDefaultAsync(); + } + + + public async Task GetSeriesByAnyName(IList names, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) + { + var libraryIds = GetLibraryIdsForUser(userId); + names = names.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); + var normalizedNames = names.Select(s => s.ToNormalized()).ToList(); + + + var query = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Where(s => formats.Contains(s.Format)); + + if (aniListId.HasValue && aniListId.Value > 0) + { + // If AniList ID is provided, override name checks + query = query.Where(s => s.ExternalSeriesMetadata.AniListId == aniListId.Value || + normalizedNames.Contains(s.NormalizedName) + || normalizedNames.Contains(s.NormalizedLocalizedName) + || names.Contains(s.OriginalName)); + } + else + { + // Otherwise, use name checks + query = query.Where(s => + normalizedNames.Contains(s.NormalizedName) + || normalizedNames.Contains(s.NormalizedLocalizedName) + || names.Contains(s.OriginalName)); + } + + return await query + .Includes(includes) .FirstOrDefaultAsync(); } @@ -1791,7 +1872,7 @@ public class SeriesRepository : ISeriesRepository .ToList(); // Prefer the first match or handle duplicates by choosing the last one - if (matchingSeries.Any()) + if (matchingSeries.Count != 0) { ids.Add(matchingSeries.Last().Id); } @@ -2037,9 +2118,6 @@ public class SeriesRepository : ISeriesRepository /// Uses multiple names to find a match against a series. If not, returns null. /// /// This does not restrict to the user at all. That is handled at the API level. - /// - /// - /// public async Task GetSeriesDtoByNamesAndMetadataIds(IEnumerable names, LibraryType libraryType, string aniListUrl, string malUrl) { var libraryIds = await _context.Library @@ -2073,6 +2151,47 @@ public class SeriesRepository : ISeriesRepository .FirstOrDefaultAsync(); // Some users may have improperly configured libraries } + public async Task MatchSeries(ExternalSeriesDetailDto externalSeries) + { + var libraryIds = await _context.Library + .Where(lib => externalSeries.PlusMediaFormat.ConvertToLibraryTypes().Contains(lib.Type)) + .Select(l => l.Id) + .ToListAsync(); + + var normalizedNames = (externalSeries.Synonyms ?? Enumerable.Empty()) + .Prepend(externalSeries.Name) + .Select(n => n.ToNormalized()) + .ToList(); + + var aniListWebLink = + ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, externalSeries.AniListId); + var malWebLink = + ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, externalSeries.MALId); + + Series? result = null; + if (!string.IsNullOrEmpty(aniListWebLink) || !string.IsNullOrEmpty(malWebLink)) + { + result = await _context.Series + .Where(s => !string.IsNullOrEmpty(s.Metadata.WebLinks)) + .Where(s => libraryIds.Contains(s.Library.Id)) + .WhereIf(!string.IsNullOrEmpty(aniListWebLink), s => s.Metadata.WebLinks.Contains(aniListWebLink)) + .WhereIf(!string.IsNullOrEmpty(malWebLink), s => s.Metadata.WebLinks.Contains(malWebLink)) + .Include(s => s.Metadata) + .AsSplitQuery() + .FirstOrDefaultAsync(); + } + + if (result != null) return result; + + return await _context.Series + .Where(s => normalizedNames.Contains(s.NormalizedName) || + normalizedNames.Contains(s.NormalizedLocalizedName)) + .Where(s => libraryIds.Contains(s.Library.Id)) + .AsSplitQuery() + .Include(s => s.Metadata) + .FirstOrDefaultAsync(); // Some users may have improperly configured libraries + } + /// /// Returns the Average rating for all users within Kavita instance /// diff --git a/API/Data/Repositories/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs index 6d67b36b5..90246e75f 100644 --- a/API/Data/Repositories/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -1,24 +1,32 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; using AutoMapper; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface ISettingsRepository { void Update(ServerSetting settings); + void Update(MetadataSettings settings); + void RemoveRange(List fieldMappings); Task GetSettingsDtoAsync(); Task GetSettingAsync(ServerSettingKey key); Task> GetSettingsAsync(); void Remove(ServerSetting setting); Task GetExternalSeriesMetadata(int seriesId); + Task GetMetadataSettings(); + Task GetMetadataSettingDto(); } public class SettingsRepository : ISettingsRepository { @@ -36,6 +44,16 @@ public class SettingsRepository : ISettingsRepository _context.Entry(settings).State = EntityState.Modified; } + public void Update(MetadataSettings settings) + { + _context.Entry(settings).State = EntityState.Modified; + } + + public void RemoveRange(List fieldMappings) + { + _context.MetadataFieldMapping.RemoveRange(fieldMappings); + } + public void Remove(ServerSetting setting) { _context.Remove(setting); @@ -48,6 +66,21 @@ public class SettingsRepository : ISettingsRepository .FirstOrDefaultAsync(); } + public async Task GetMetadataSettings() + { + return await _context.MetadataSettings + .Include(m => m.FieldMappings) + .FirstAsync(); + } + + public async Task GetMetadataSettingDto() + { + return await _context.MetadataSettings + .Include(m => m.FieldMappings) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstAsync(); + } + public async Task GetSettingsDtoAsync() { var settings = await _context.ServerSetting diff --git a/API/Data/Repositories/SiteThemeRepository.cs b/API/Data/Repositories/SiteThemeRepository.cs index 2498dfa60..33517e846 100644 --- a/API/Data/Repositories/SiteThemeRepository.cs +++ b/API/Data/Repositories/SiteThemeRepository.cs @@ -8,6 +8,7 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface ISiteThemeRepository { diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index 4a7fbf4ab..40d40a675 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -2,15 +2,18 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface ITagRepository { @@ -22,6 +25,7 @@ public interface ITagRepository Task RemoveAllTagNoLongerAssociated(); Task> GetAllTagDtosForLibrariesAsync(int userId, IList? libraryIds = null); Task> GetAllTagsNotInListAsync(ICollection tags); + Task> GetBrowseableTag(int userId, UserParams userParams); } public class TagRepository : ITagRepository @@ -103,6 +107,40 @@ public class TagRepository : ITagRepository return missingTags.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); } + public async Task> GetBrowseableTag(int userId, UserParams userParams) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id); + + var query = _context.Tag + .RestrictAgainstAgeRestriction(ageRating) + .WhereIf(userLibs.Count != allLibrariesCount, + tag => tag.Chapters.Any(cp => seriesIds.Contains(cp.Volume.SeriesId)) || + tag.SeriesMetadatas.Any(sm => seriesIds.Contains(sm.SeriesId))) + .Select(g => new BrowseTagDto + { + Id = g.Id, + Title = g.Title, + SeriesCount = g.SeriesMetadatas + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + ChapterCount = g.Chapters + .Where(ch => allLibrariesCount == userLibs.Count || seriesIds.Contains(ch.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count() + }) + .OrderBy(g => g.Title); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + public async Task> GetAllTagsAsync() { return await _context.Tag.ToListAsync(); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 40e614e59..6437cfcfe 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -7,6 +7,7 @@ using API.DTOs; using API.DTOs.Account; using API.DTOs.Dashboard; using API.DTOs.Filtering.v2; +using API.DTOs.KavitaPlus.Account; using API.DTOs.Reader; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -15,12 +16,14 @@ using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions.Filtering; +using API.Helpers; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum AppUserIncludes @@ -39,7 +42,8 @@ public enum AppUserIncludes DashboardStreams = 2048, SideNavStreams = 4096, ExternalSources = 8192, - Collections = 16384 // 2^14 + Collections = 16384, // 2^14 + ChapterRatings = 1 << 15, } public interface IUserRepository @@ -54,13 +58,17 @@ public interface IUserRepository void Delete(AppUser? user); void Delete(AppUserBookmark bookmark); void Delete(IEnumerable streams); + void Delete(AppUserDashboardStream stream); void Delete(IEnumerable streams); + void Delete(AppUserSideNavStream stream); Task> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true); Task> GetAdminUsersAsync(); Task IsUserAdminAsync(AppUser? user); Task> GetRoles(int userId); Task GetUserRatingAsync(int seriesId, int userId); + Task GetUserChapterRatingAsync(int userId, int chapterId); Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId); + Task> GetUserRatingDtosForChapterAsync(int chapterId, int userId); Task GetPreferencesAsync(string username); Task> GetBookmarkDtosForSeries(int userId, int seriesId); Task> GetBookmarkDtosForVolume(int userId, int volumeId); @@ -92,10 +100,13 @@ public interface IUserRepository Task> GetDashboardStreamWithFilter(int filterId); Task> GetSideNavStreams(int userId, bool visibleOnly = false); Task GetSideNavStream(int streamId); + Task GetSideNavStreamWithUser(int streamId); Task> GetSideNavStreamWithFilter(int filterId); Task> GetSideNavStreamsByLibraryId(int libraryId); Task> GetSideNavStreamWithExternalSource(int externalSourceId); Task> GetDashboardStreamsByIds(IList streamIds); + Task> GetUserTokenInfo(); + Task GetUserByDeviceEmail(string deviceEmail); } public class UserRepository : IUserRepository @@ -162,11 +173,21 @@ public class UserRepository : IUserRepository _context.AppUserDashboardStream.RemoveRange(streams); } + public void Delete(AppUserDashboardStream stream) + { + _context.AppUserDashboardStream.Remove(stream); + } + public void Delete(IEnumerable streams) { _context.AppUserSideNavStream.RemoveRange(streams); } + public void Delete(AppUserSideNavStream stream) + { + _context.AppUserSideNavStream.Remove(stream); + } + /// /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// @@ -391,6 +412,7 @@ public class UserRepository : IUserRepository .FirstOrDefaultAsync(d => d.Id == streamId); } + public async Task> GetDashboardStreamWithFilter(int filterId) { return await _context.AppUserDashboardStream @@ -427,10 +449,10 @@ public class UserRepository : IUserRepository .Select(d => d.LibraryId) .ToList(); - var libraryDtos = _context.Library + var libraryDtos = await _context.Library .Where(l => libraryIds.Contains(l.Id)) .ProjectTo(_mapper.ConfigurationProvider) - .ToList(); + .ToListAsync(); foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.Library)) { @@ -454,13 +476,21 @@ public class UserRepository : IUserRepository return sideNavStreams; } - public async Task GetSideNavStream(int streamId) + public async Task GetSideNavStream(int streamId) { return await _context.AppUserSideNavStream .Include(d => d.SmartFilter) .FirstOrDefaultAsync(d => d.Id == streamId); } + public async Task GetSideNavStreamWithUser(int streamId) + { + return await _context.AppUserSideNavStream + .Include(d => d.SmartFilter) + .Include(d => d.AppUser) + .FirstOrDefaultAsync(d => d.Id == streamId); + } + public async Task> GetSideNavStreamWithFilter(int filterId) { return await _context.AppUserSideNavStream @@ -490,6 +520,43 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task> GetUserTokenInfo() + { + var users = await _context.AppUser + .Select(u => new + { + u.Id, + u.UserName, + u.AniListAccessToken, // JWT Token + u.MalAccessToken // JWT Token + }) + .ToListAsync(); + + var userTokenInfos = users.Select(user => new UserTokenInfo + { + UserId = user.Id, + Username = user.UserName, + IsAniListTokenSet = !string.IsNullOrEmpty(user.AniListAccessToken), + AniListValidUntilUtc = JwtHelper.GetTokenExpiry(user.AniListAccessToken), + IsAniListTokenValid = JwtHelper.IsTokenValid(user.AniListAccessToken), + IsMalTokenSet = !string.IsNullOrEmpty(user.MalAccessToken), + }); + + return userTokenInfos; + } + + /// + /// Returns the first user with a device email matching + /// + /// + /// + public async Task GetUserByDeviceEmail(string deviceEmail) + { + return await _context.AppUser + .Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail)) + .FirstOrDefaultAsync(); + } + public async Task> GetAdminUsersAsync() { @@ -505,7 +572,16 @@ public class UserRepository : IUserRepository public async Task> GetRoles(int userId) { var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId); - if (user == null || _userManager == null) return ArraySegment.Empty; // userManager is null on Unit Tests only + if (user == null) return ArraySegment.Empty; + + if (_userManager == null) + { + // userManager is null on Unit Tests only + return await _context.UserRoles + .Where(ur => ur.UserId == userId) + .Select(ur => ur.Role.Name) + .ToListAsync(); + } return await _userManager.GetRolesAsync(user); } @@ -514,7 +590,14 @@ public class UserRepository : IUserRepository { return await _context.AppUserRating .Where(r => r.SeriesId == seriesId && r.AppUserId == userId) - .SingleOrDefaultAsync(); + .FirstOrDefaultAsync(); + } + + public async Task GetUserChapterRatingAsync(int userId, int chapterId) + { + return await _context.AppUserChapterRating + .Where(r => r.AppUserId == userId && r.ChapterId == chapterId) + .FirstOrDefaultAsync(); } public async Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId) @@ -530,6 +613,19 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task> GetUserRatingDtosForChapterAsync(int chapterId, int userId) + { + return await _context.AppUserChapterRating + .Include(r => r.AppUser) + .Where(r => r.ChapterId == chapterId) + .Where(r => r.AppUser.UserPreferences.ShareReviews || r.AppUserId == userId) + .OrderBy(r => r.AppUserId == userId) + .ThenBy(r => r.Rating) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + public async Task GetPreferencesAsync(string username) { return await _context.AppUserPreferences @@ -661,7 +757,7 @@ public class UserRepository : IUserRepository /// - /// Fetches the UserId by API Key. This does not include any extra information + /// Fetches the AppUserId by API Key. This does not include any extra information /// /// /// diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 0dfbd6393..4b07ade96 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -15,6 +15,7 @@ using Kavita.Common; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum VolumeIncludes @@ -34,6 +35,7 @@ public interface IVolumeRepository void Add(Volume volume); void Update(Volume volume); void Remove(Volume volume); + void Remove(IList volumes); Task> GetFilesForVolume(int volumeId); Task GetVolumeCoverImageAsync(int volumeId); Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); @@ -42,6 +44,7 @@ public interface IVolumeRepository Task GetVolumeDtoAsync(int volumeId, int userId); Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task> GetVolumes(int seriesId); + Task> GetVolumesById(IList volumeIds, VolumeIncludes includes = VolumeIncludes.None); Task GetVolumeByIdAsync(int volumeId); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task> GetCoverImagesForLockedVolumesAsync(); @@ -71,6 +74,10 @@ public class VolumeRepository : IVolumeRepository { _context.Volume.Remove(volume); } + public void Remove(IList volumes) + { + _context.Volume.RemoveRange(volumes); + } /// /// Returns a list of non-tracked files for a given volume. @@ -179,6 +186,15 @@ public class VolumeRepository : IVolumeRepository .OrderBy(vol => vol.MinNumber) .ToListAsync(); } + public async Task> GetVolumesById(IList volumeIds, VolumeIncludes includes = VolumeIncludes.None) + { + return await _context.Volume + .Where(vol => volumeIds.Contains(vol.Id)) + .Includes(includes) + .AsSplitQuery() + .OrderBy(vol => vol.MinNumber) + .ToListAsync(); + } /// /// Returns a single volume with Chapter and Files diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 85971558c..c08f80afa 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -10,6 +11,7 @@ using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.Theme; +using API.Entities.MetadataMatching; using API.Extensions; using API.Services; using Kavita.Common; @@ -118,7 +120,7 @@ public static class Seed new AppUserSideNavStream() { Name = "browse-authors", - StreamType = SideNavStreamType.BrowseAuthors, + StreamType = SideNavStreamType.BrowsePeople, Order = 6, IsProvided = true, Visible = true @@ -261,13 +263,12 @@ public static class Seed new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty}, new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"}, new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()}, - new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString()}, - + new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)}, }.ToArray()); foreach (var defaultSetting in DefaultSettings) { - var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); + var existing = await context.ServerSetting.FirstOrDefaultAsync(s => s.Key == defaultSetting.Key); if (existing == null) { await context.ServerSetting.AddAsync(defaultSetting); @@ -277,16 +278,51 @@ public static class Seed await context.SaveChangesAsync(); // Port, IpAddresses and LoggingLevel are managed in appSettings.json. Update the DB values to match - context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.Port)).Value = Configuration.Port + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.IpAddresses).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.IpAddresses)).Value = Configuration.IpAddresses; - context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheDirectory)).Value = directoryService.CacheDirectory + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.BackupDirectory)).Value = DirectoryService.BackupDirectory + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheSize).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value = Configuration.CacheSize + string.Empty; + + await context.SaveChangesAsync(); + } + + public static async Task SeedMetadataSettings(DataContext context) + { + await context.Database.EnsureCreatedAsync(); + + var existing = await context.MetadataSettings.FirstOrDefaultAsync(); + if (existing == null) + { + existing = new MetadataSettings() + { + Enabled = true, + EnablePeople = true, + EnableRelationships = true, + EnableSummary = true, + EnablePublicationStatus = true, + EnableStartDate = true, + EnableTags = false, + EnableGenres = true, + EnableLocalizedName = false, + FirstLastPeopleNaming = true, + EnableCoverImage = true, + EnableChapterTitle = false, + EnableChapterSummary = true, + EnableChapterPublisher = true, + EnableChapterCoverImage = false, + EnableChapterReleaseDate = true, + PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character] + }; + await context.MetadataSettings.AddAsync(existing); + } + + await context.SaveChangesAsync(); } diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index e3c1ffcb1..d72dd3bc7 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -32,6 +32,8 @@ public interface IUnitOfWork IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } + IEmailHistoryRepository EmailHistoryRepository { get; } + IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -72,6 +74,8 @@ public class UnitOfWork : IUnitOfWork AppUserSmartFilterRepository = new AppUserSmartFilterRepository(_context, _mapper); AppUserExternalSourceRepository = new AppUserExternalSourceRepository(_context, _mapper); ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper); + EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper); + AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper); } /// @@ -100,6 +104,8 @@ public class UnitOfWork : IUnitOfWork public IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } public IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } + public IEmailHistoryRepository EmailHistoryRepository { get; } + public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/EmailTemplates/KavitaPlusDebug.html b/API/EmailTemplates/KavitaPlusDebug.html new file mode 100644 index 000000000..e165dfb98 --- /dev/null +++ b/API/EmailTemplates/KavitaPlusDebug.html @@ -0,0 +1,20 @@ + + +

A User needs manual registration

+ + + +
+ + + + + + +
+ + diff --git a/API/EmailTemplates/TokenExpiration.html b/API/EmailTemplates/TokenExpiration.html new file mode 100644 index 000000000..1162dc75b --- /dev/null +++ b/API/EmailTemplates/TokenExpiration.html @@ -0,0 +1,28 @@ + + +

Your {{Provider}} Token is Expired!

+ + + +

Kavita will stop syncing with {{Provider}} until you renew your token.

+ + + + + +
+ + + + + +
+ + + +

If the button above does not work, please find the link here: {{Link}}

+ + diff --git a/API/EmailTemplates/TokenExpiringSoon.html b/API/EmailTemplates/TokenExpiringSoon.html new file mode 100644 index 000000000..960b9b6e5 --- /dev/null +++ b/API/EmailTemplates/TokenExpiringSoon.html @@ -0,0 +1,28 @@ + + +

Your {{Provider}} Token will Expire soon!

+ + + +

Kavita will stop syncing with {{Provider}} until you renew your token.

+ + + + + +
+ + + + + +
+ + + +

If the button above does not work, please find the link here: {{Link}}

+ + diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 2e6f42d3d..848636209 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -19,7 +19,9 @@ public class AppUser : IdentityUser, IHasConcurrencyToken public ICollection UserRoles { get; set; } = null!; public ICollection Progresses { get; set; } = null!; public ICollection Ratings { get; set; } = null!; + public ICollection ChapterRatings { get; set; } = null!; public AppUserPreferences UserPreferences { get; set; } = null!; + public ICollection ReadingProfiles { get; set; } = null!; /// /// Bookmarks associated with this User /// @@ -76,6 +78,18 @@ public class AppUser : IdentityUser, IHasConcurrencyToken ///
public string? MalAccessToken { get; set; } + /// + /// Has the user ran Scrobble Event Generation + /// + /// Only applicable for Kavita+ and when a Token is present + public bool HasRunScrobbleEventGeneration { get; set; } + /// + /// The timestamp of when Scrobble Event Generation ran (Utc) + /// + /// Kavita+ only + public DateTime ScrobbleEventGenerationRan { get; set; } + + /// /// A list of Series the user doesn't want scrobbling for /// diff --git a/API/Entities/AppUserChapterRating.cs b/API/Entities/AppUserChapterRating.cs new file mode 100644 index 000000000..a78096bda --- /dev/null +++ b/API/Entities/AppUserChapterRating.cs @@ -0,0 +1,30 @@ +namespace API.Entities; + +public class AppUserChapterRating +{ + public int Id { get; set; } + /// + /// A number between 0-5.0 that represents how good a series is. + /// + public float Rating { get; set; } + /// + /// If the rating has been explicitly set. Otherwise, the 0.0 rating should be ignored as it's not rated + /// + public bool HasBeenRated { get; set; } + /// + /// A short summary the user can write when giving their review. + /// + public string? Review { get; set; } + /// + /// An optional tagline for the review + /// + public int SeriesId { get; set; } + public Series Series { get; set; } = null!; + + public int ChapterId { get; set; } + public Chapter Chapter { get; set; } = null!; + + // Relationships + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } = null!; +} diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 006d9037c..b0f21bcba 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -1,4 +1,5 @@ -using API.Data; +using System.Collections.Generic; +using API.Data; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; @@ -54,6 +55,10 @@ public class AppUserPreferences /// Manga Reader Option: Should swiping trigger pagination ///
public bool SwipeToPaginate { get; set; } + /// + /// Manga Reader Option: Allow Automatic Webtoon detection + /// + public bool AllowAutomaticWebtoonReaderDetection { get; set; } #endregion @@ -160,7 +165,17 @@ public class AppUserPreferences /// UI Site Global Setting: The language locale that should be used for the user ///
public string Locale { get; set; } + #endregion + #region KavitaPlus + /// + /// Should this account have Scrobbling enabled for AniList + /// + public bool AniListScrobblingEnabled { get; set; } + /// + /// Should this account have Want to Read Sync enabled + /// + public bool WantToReadSync { get; set; } #endregion public AppUser AppUser { get; set; } = null!; diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index edbd25aa7..beaf07220 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -7,7 +7,7 @@ namespace API.Entities; /// /// Represents the progress a single user has on a given Chapter. /// -public class AppUserProgress +public class AppUserProgress : IEntityDate { /// /// Id of Entity diff --git a/API/Entities/AppUserRating.cs b/API/Entities/AppUserRating.cs index 5d66a06e4..e76838926 100644 --- a/API/Entities/AppUserRating.cs +++ b/API/Entities/AppUserRating.cs @@ -26,7 +26,6 @@ public class AppUserRating public int SeriesId { get; set; } public Series Series { get; set; } = null!; - // Relationships public int AppUserId { get; set; } public AppUser AppUser { get; set; } = null!; diff --git a/API/Entities/AppUserReadingProfile.cs b/API/Entities/AppUserReadingProfile.cs new file mode 100644 index 000000000..9b238b4f5 --- /dev/null +++ b/API/Entities/AppUserReadingProfile.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.ComponentModel; +using API.Entities.Enums; +using API.Entities.Enums.UserPreferences; + +namespace API.Entities; + +public enum BreakPoint +{ + [Description("Never")] + Never = 0, + [Description("Mobile")] + Mobile = 1, + [Description("Tablet")] + Tablet = 2, + [Description("Desktop")] + Desktop = 3, +} + +public class AppUserReadingProfile +{ + public int Id { get; set; } + + public string Name { get; set; } + public string NormalizedName { get; set; } + + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } + + public ReadingProfileKind Kind { get; set; } + public List LibraryIds { get; set; } + public List SeriesIds { get; set; } + + #region MangaReader + + /// + /// Manga Reader Option: What direction should the next/prev page buttons go + /// + public ReadingDirection ReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// Manga Reader Option: How should the image be scaled to screen + /// + public ScalingOption ScalingOption { get; set; } = ScalingOption.Automatic; + /// + /// Manga Reader Option: Which side of a split image should we show first + /// + public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.FitSplit; + /// + /// Manga Reader Option: How the manga reader should perform paging or reading of the file + /// + /// Webtoon uses scrolling to page, MANGA_LR uses paging by clicking left/right side of reader, MANGA_UD uses paging + /// by clicking top/bottom sides of reader. + /// + /// + public ReaderMode ReaderMode { get; set; } + /// + /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction + /// + public bool AutoCloseMenu { get; set; } = true; + /// + /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change + /// + public bool ShowScreenHints { get; set; } = true; + /// + /// Manga Reader Option: Emulate a book by applying a shadow effect on the pages + /// + public bool EmulateBook { get; set; } = false; + /// + /// Manga Reader Option: How many pages to display in the reader at once + /// + public LayoutMode LayoutMode { get; set; } = LayoutMode.Single; + /// + /// Manga Reader Option: Background color of the reader + /// + public string BackgroundColor { get; set; } = "#000000"; + /// + /// Manga Reader Option: Should swiping trigger pagination + /// + public bool SwipeToPaginate { get; set; } + /// + /// Manga Reader Option: Allow Automatic Webtoon detection + /// + public bool AllowAutomaticWebtoonReaderDetection { get; set; } + /// + /// Manga Reader Option: Optional fixed width override + /// + public int? WidthOverride { get; set; } = null; + /// + /// Manga Reader Option: Disable the width override if the screen is past the breakpoint + /// + public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never; + + #endregion + + #region EpubReader + + /// + /// Book Reader Option: Override extra Margin + /// + public int BookReaderMargin { get; set; } = 15; + /// + /// Book Reader Option: Override line-height + /// + public int BookReaderLineSpacing { get; set; } = 100; + /// + /// Book Reader Option: Override font size + /// + public int BookReaderFontSize { get; set; } = 100; + /// + /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override + /// + public string BookReaderFontFamily { get; set; } = "default"; + /// + /// Book Reader Option: Allows tapping on side of screens to paginate + /// + public bool BookReaderTapToPaginate { get; set; } = false; + /// + /// Book Reader Option: What direction should the next/prev page buttons go + /// + public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// Book Reader Option: Defines the writing styles vertical/horizontal + /// + public WritingStyle BookReaderWritingStyle { get; set; } = WritingStyle.Horizontal; + /// + /// Book Reader Option: The color theme to decorate the book contents + /// + /// Should default to Dark + public string BookThemeName { get; set; } = "Dark"; + /// + /// Book Reader Option: The way a page from a book is rendered. Default is as book dictates, 1 column is fit to height, + /// 2 column is fit to height, 2 columns + /// + /// Defaults to Default + public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default; + /// + /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. + /// + /// Defaults to false + public bool BookReaderImmersiveMode { get; set; } = false; + #endregion + + #region PdfReader + + /// + /// PDF Reader: Theme of the Reader + /// + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + /// + /// PDF Reader: Scroll mode of the reader + /// + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + /// + /// PDF Reader: Spread Mode of the reader + /// + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; + + + #endregion +} diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index a00f315c3..fe3646943 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -3,12 +3,15 @@ using System.Collections.Generic; using System.Globalization; using API.Entities.Enums; using API.Entities.Interfaces; +using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Services.Tasks.Scanner.Parser; namespace API.Entities; -public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage +public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasKPlusMetadata { public int Id { get; set; } /// @@ -124,6 +127,16 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage public string WebLinks { get; set; } = string.Empty; public string ISBN { get; set; } = string.Empty; + /// + /// Tracks which metadata has been set by K+ + /// + public IList KPlusOverrides { get; set; } = []; + + /// + /// (Kavita+) Average rating from Kavita+ metadata + /// + public float AverageExternalRating { get; set; } = 0f; + #region Locks public bool AgeRatingLocked { get; set; } @@ -159,6 +172,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage /// public ICollection Genres { get; set; } = new List(); public ICollection Tags { get; set; } = new List(); + public ICollection Ratings { get; set; } = []; public ICollection UserProgress { get; set; } @@ -167,6 +181,9 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage public Volume Volume { get; set; } = null!; public int VolumeId { get; set; } + public ICollection ExternalReviews { get; set; } = []; + public ICollection ExternalRatings { get; set; } = null!; + public void UpdateFrom(ParserInfo info) { Files ??= new List(); @@ -177,8 +194,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage MinNumber = Parser.DefaultChapterNumber; MaxNumber = Parser.DefaultChapterNumber; } - // NOTE: This doesn't work well for all because Pdf usually should use into.Title or even filename - Title = (IsSpecial && info.Format == MangaFormat.Epub) + Title = (IsSpecial && info.Format is MangaFormat.Epub or MangaFormat.Pdf) ? info.Title : Parser.RemoveExtensionIfSupported(Range); @@ -192,8 +208,6 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage /// public string GetNumberTitle() { - // BUG: TODO: On non-english locales, for floats, the range will be 20,5 but the NumberTitle will return 20.5 - // Have I fixed this with TryParse CultureInvariant try { if (MinNumber.Is(MaxNumber)) @@ -234,4 +248,25 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage PrimaryColor = string.Empty; SecondaryColor = string.Empty; } + + public bool IsPersonRoleLocked(PersonRole role) + { + return role switch + { + PersonRole.Character => CharacterLocked, + PersonRole.Writer => WriterLocked, + PersonRole.Penciller => PencillerLocked, + PersonRole.Inker => InkerLocked, + PersonRole.Colorist => ColoristLocked, + PersonRole.Letterer => LettererLocked, + PersonRole.CoverArtist => CoverArtistLocked, + PersonRole.Editor => EditorLocked, + PersonRole.Publisher => PublisherLocked, + PersonRole.Translator => TranslatorLocked, + PersonRole.Imprint => ImprintLocked, + PersonRole.Team => TeamLocked, + PersonRole.Location => LocationLocked, + _ => throw new ArgumentOutOfRangeException(nameof(role), role, null) + }; + } } diff --git a/API/Entities/EmailHistory.cs b/API/Entities/EmailHistory.cs new file mode 100644 index 000000000..f1ab95ca5 --- /dev/null +++ b/API/Entities/EmailHistory.cs @@ -0,0 +1,31 @@ +using System; +using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities; + +/// +/// Records all emails that are sent from Kavita +/// +[Index("Sent", "AppUserId", "EmailTemplate", "SendDate")] +public class EmailHistory : IEntityDate +{ + public long Id { get; set; } + public bool Sent { get; set; } + public DateTime SendDate { get; set; } = DateTime.UtcNow; + public string EmailTemplate { get; set; } + public string Subject { get; set; } + public string Body { get; set; } + + public string DeliveryStatus { get; set; } + public string ErrorMessage { get; set; } + + public int AppUserId { get; set; } + public virtual AppUser AppUser { get; set; } + + + public DateTime Created { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModified { get; set; } + public DateTime LastModifiedUtc { get; set; } +} diff --git a/API/Entities/Enums/LibraryType.cs b/API/Entities/Enums/LibraryType.cs index 40d1b10a8..a8d943b2d 100644 --- a/API/Entities/Enums/LibraryType.cs +++ b/API/Entities/Enums/LibraryType.cs @@ -12,7 +12,7 @@ public enum LibraryType /// /// Uses Comic regex for filename parsing /// - [Description("Comic")] + [Description("Comic (Legacy)")] Comic = 1, /// /// Uses Manga regex for filename parsing also uses epub metadata @@ -30,8 +30,8 @@ public enum LibraryType [Description("Light Novel")] LightNovel = 4, /// - /// Uses Comic regex for filename parsing, uses Comic Vine type of Parsing. Will replace Comic type in future + /// Uses Comic regex for filename parsing, uses Comic Vine type of Parsing /// - [Description("Comic (Comic Vine)")] + [Description("Comic")] ComicVine = 5, } diff --git a/API/Entities/Enums/MetadataFieldType.cs b/API/Entities/Enums/MetadataFieldType.cs new file mode 100644 index 000000000..0052b6599 --- /dev/null +++ b/API/Entities/Enums/MetadataFieldType.cs @@ -0,0 +1,7 @@ +namespace API.Entities.Enums; + +public enum MetadataFieldType +{ + Genre = 0, + Tag = 1, +} diff --git a/API/Entities/Enums/RatingAuthority.cs b/API/Entities/Enums/RatingAuthority.cs new file mode 100644 index 000000000..0f358a9a7 --- /dev/null +++ b/API/Entities/Enums/RatingAuthority.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +public enum RatingAuthority +{ + /// + /// Rating was from a User (internet or local) + /// + [Description("User")] + User = 0, + /// + /// Rating was from Professional Critics + /// + [Description("Critic")] + Critic = 1, +} diff --git a/API/Entities/Enums/ReadingProfileKind.cs b/API/Entities/Enums/ReadingProfileKind.cs new file mode 100644 index 000000000..0f9cfa20b --- /dev/null +++ b/API/Entities/Enums/ReadingProfileKind.cs @@ -0,0 +1,17 @@ +namespace API.Entities.Enums; + +public enum ReadingProfileKind +{ + /// + /// Generate by Kavita when registering a user, this is your default profile + /// + Default, + /// + /// Created by the user in the UI or via the API + /// + User, + /// + /// Automatically generated by Kavita to track changes made in the readers. Can be converted to a User Reading Profile. + /// + Implicit +} diff --git a/API/Entities/History/KavitaPlusHistory.cs b/API/Entities/History/KavitaPlusHistory.cs new file mode 100644 index 000000000..81b7e5e40 --- /dev/null +++ b/API/Entities/History/KavitaPlusHistory.cs @@ -0,0 +1,9 @@ +namespace API.Entities.History; + +/// +/// Records history of actions Kavita+ takes +/// +// public class KavitaPlusHistory +// { +// +// } diff --git a/API/Entities/ManualMigrationHistory.cs b/API/Entities/History/ManualMigrationHistory.cs similarity index 91% rename from API/Entities/ManualMigrationHistory.cs rename to API/Entities/History/ManualMigrationHistory.cs index e65e07b2c..2f407ca1d 100644 --- a/API/Entities/ManualMigrationHistory.cs +++ b/API/Entities/History/ManualMigrationHistory.cs @@ -1,6 +1,6 @@ using System; -namespace API.Entities; +namespace API.Entities.History; /// /// This will track manual migrations so that I can use simple selects to check if a Manual Migration is needed diff --git a/API/Entities/Interfaces/IHasKPlusMetadata.cs b/API/Entities/Interfaces/IHasKPlusMetadata.cs new file mode 100644 index 000000000..062afd7e1 --- /dev/null +++ b/API/Entities/Interfaces/IHasKPlusMetadata.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using API.Entities.MetadataMatching; + +namespace API.Entities.Interfaces; + +public interface IHasKPlusMetadata +{ + /// + /// Tracks which metadata has been set by K+ + /// + public IList KPlusOverrides { get; set; } +} diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index 097c382d5..4a48fed99 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -40,8 +40,22 @@ public class Library : IEntityDate, IHasCoverImage /// /// Should this library allow Scrobble events to emit from it /// - /// Scrobbling requires a valid LicenseKey + /// Requires a valid LicenseKey public bool AllowScrobbling { get; set; } = true; + /// + /// Allow any series within this Library to download metadata. + /// + /// This does not exclude the library from being linked to wrt Series Relationships + /// Requires a valid LicenseKey + public bool AllowMetadataMatching { get; set; } = true; + /// + /// Should Kavita read metadata files from the library + /// + public bool EnableMetadata { get; set; } = true; + /// + /// Should Kavita remove sort articles "The" for the sort name + /// + public bool RemovePrefixForSortName { get; set; } = false; public DateTime Created { get; set; } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index f104f4c72..afcb23e97 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -21,6 +21,11 @@ public class MangaFile : IEntityDate /// public required string FilePath { get; set; } /// + /// A hash of the document using Koreader's unique hashing algorithm + /// + /// KoreaderHash is only available for epub types + public string? KoreaderHash { get; set; } + /// /// Number of pages for the given file /// public int Pages { get; set; } diff --git a/API/Entities/Metadata/ExternalRating.cs b/API/Entities/Metadata/ExternalRating.cs index b325353e4..7fc2b9353 100644 --- a/API/Entities/Metadata/ExternalRating.cs +++ b/API/Entities/Metadata/ExternalRating.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.Entities.Enums; using API.Services.Plus; namespace API.Entities.Metadata; @@ -10,8 +11,16 @@ public class ExternalRating public int AverageScore { get; set; } public int FavoriteCount { get; set; } public ScrobbleProvider Provider { get; set; } + /// + /// Where this rating comes from: Critic or User + /// + public RatingAuthority Authority { get; set; } = RatingAuthority.User; public string? ProviderUrl { get; set; } public int SeriesId { get; set; } + /// + /// This can be null when for a series-rating + /// + public int? ChapterId { get; set; } public ICollection ExternalSeriesMetadatas { get; set; } = null!; } diff --git a/API/Entities/Metadata/ExternalReview.cs b/API/Entities/Metadata/ExternalReview.cs index 6304d98ad..73c71e5ee 100644 --- a/API/Entities/Metadata/ExternalReview.cs +++ b/API/Entities/Metadata/ExternalReview.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.Entities.Enums; using API.Services.Plus; namespace API.Entities.Metadata; @@ -20,6 +21,7 @@ public class ExternalReview /// public string RawBody { get; set; } public required ScrobbleProvider Provider { get; set; } + public RatingAuthority Authority { get; set; } = RatingAuthority.User; public string SiteUrl { get; set; } /// /// Reviewer's username @@ -37,6 +39,7 @@ public class ExternalReview public int SeriesId { get; set; } + public int? ChapterId { get; set; } // Relationships public ICollection ExternalSeriesMetadatas { get; set; } = null!; diff --git a/API/Entities/Metadata/ExternalSeriesMetadata.cs b/API/Entities/Metadata/ExternalSeriesMetadata.cs index 598d02184..1ab37ba3c 100644 --- a/API/Entities/Metadata/ExternalSeriesMetadata.cs +++ b/API/Entities/Metadata/ExternalSeriesMetadata.cs @@ -23,9 +23,10 @@ public class ExternalSeriesMetadata /// /// Average External Rating. -1 means not set, 0 - 100 /// - public int AverageExternalRating { get; set; } = 0; + public int AverageExternalRating { get; set; } = -1; public int AniListId { get; set; } + public int CbrId { get; set; } public long MalId { get; set; } public string GoogleBooksId { get; set; } diff --git a/API/Entities/Metadata/SeriesBlacklist.cs b/API/Entities/Metadata/SeriesBlacklist.cs index 09ff06153..3d262eeb4 100644 --- a/API/Entities/Metadata/SeriesBlacklist.cs +++ b/API/Entities/Metadata/SeriesBlacklist.cs @@ -5,10 +5,12 @@ namespace API.Entities.Metadata; /// /// A blacklist of Series for Kavita+ /// +[Obsolete("Kavita v0.8.5 moved the implementation to Series.IsBlacklisted")] public class SeriesBlacklist { public int Id { get; set; } + public DateTime LastChecked { get; set; } = DateTime.UtcNow; + public int SeriesId { get; set; } public Series Series { get; set; } - public DateTime LastChecked { get; set; } = DateTime.UtcNow; } diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 6e594aa73..8bb33fdc0 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -1,14 +1,17 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using API.Entities.Enums; using API.Entities.Interfaces; +using API.Entities.MetadataMatching; +using API.Entities.Person; using Microsoft.EntityFrameworkCore; namespace API.Entities.Metadata; [Index(nameof(Id), nameof(SeriesId), IsUnique = true)] -public class SeriesMetadata : IHasConcurrencyToken +public class SeriesMetadata : IHasConcurrencyToken, IHasKPlusMetadata { public int Id { get; set; } @@ -40,6 +43,10 @@ public class SeriesMetadata : IHasConcurrencyToken /// /// This is not populated from Chapters of the Series public string WebLinks { get; set; } = string.Empty; + /// + /// Tracks which metadata has been set by K+ + /// + public IList KPlusOverrides { get; set; } = []; #region Locks @@ -101,4 +108,26 @@ public class SeriesMetadata : IHasConcurrencyToken { RowVersion++; } + + /// + /// Any People in this Role present + /// + /// + /// + public bool AnyOfRole(PersonRole role) + { + return People.Any(p => p.Role == role); + } + + /// + /// Are all instances of the role from Kavita+ + /// + /// + /// + public bool AllKavitaPlus(PersonRole role) + { + var people = People.Where(p => p.Role == role); + if (people.Any()) return people.All(p => p.KavitaPlusConnection); + return false; + } } diff --git a/API/Entities/MetadataMatching/MetadataFieldMapping.cs b/API/Entities/MetadataMatching/MetadataFieldMapping.cs new file mode 100644 index 000000000..e7dd88c03 --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataFieldMapping.cs @@ -0,0 +1,26 @@ +using API.Entities.Enums; +using API.Entities.MetadataMatching; + +namespace API.Entities; + +public class MetadataFieldMapping +{ + public int Id { get; set; } + public MetadataFieldType SourceType { get; set; } + public MetadataFieldType DestinationType { get; set; } + /// + /// The string in the source + /// + public string SourceValue { get; set; } + /// + /// Write the string as this in the Destination (can also just be the Source) + /// + public string DestinationValue { get; set; } + /// + /// If true, the tag will be Moved over vs Copied over + /// + public bool ExcludeFromSource { get; set; } + + public int MetadataSettingsId { get; set; } + public virtual MetadataSettings MetadataSettings { get; set; } +} diff --git a/API/Entities/MetadataMatching/MetadataSettingField.cs b/API/Entities/MetadataMatching/MetadataSettingField.cs new file mode 100644 index 000000000..9333c269e --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataSettingField.cs @@ -0,0 +1,31 @@ +namespace API.Entities.MetadataMatching; + +/// +/// Represents which field that can be written to as an override when already locked +/// +public enum MetadataSettingField +{ + #region Series Metadata + Summary = 1, + PublicationStatus = 2, + StartDate = 3, + Genres = 4, + Tags = 5, + LocalizedName = 6, + Covers = 7, + AgeRating = 8, + People = 9, + #endregion + + #region Chapter Metadata + + ChapterTitle = 10, + ChapterSummary = 11, + ChapterReleaseDate = 12, + ChapterPublisher = 13, + ChapterCovers = 14, + + #endregion + + +} diff --git a/API/Entities/MetadataMatching/MetadataSettings.cs b/API/Entities/MetadataMatching/MetadataSettings.cs new file mode 100644 index 000000000..aeb44b619 --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataSettings.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.Entities.MetadataMatching; + +/// +/// Handles the metadata settings for Kavita+ +/// +public class MetadataSettings +{ + public int Id { get; set; } + /// + /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed + /// + public bool Enabled { get; set; } + + #region Series Metadata + + /// + /// Allow the Summary to be written + /// + public bool EnableSummary { get; set; } + /// + /// Allow Publication status to be derived and updated + /// + public bool EnablePublicationStatus { get; set; } + /// + /// Allow Relationships between series to be set + /// + public bool EnableRelationships { get; set; } + /// + /// Allow People to be created (including downloading images) + /// + public bool EnablePeople { get; set; } + /// + /// Allow Start date to be set within the Series + /// + public bool EnableStartDate { get; set; } + /// + /// Allow setting the Localized name + /// + public bool EnableLocalizedName { get; set; } + /// + /// Allow setting the cover image + /// + public bool EnableCoverImage { get; set; } + #endregion + + #region Chapter Metadata + /// + /// Allow Summary to be set within Chapter/Issue + /// + public bool EnableChapterSummary { get; set; } + /// + /// Allow Release Date to be set within Chapter/Issue + /// + public bool EnableChapterReleaseDate { get; set; } + /// + /// Allow Title to be set within Chapter/Issue + /// + public bool EnableChapterTitle { get; set; } + /// + /// Allow Publisher to be set within Chapter/Issue + /// + public bool EnableChapterPublisher { get; set; } + /// + /// Allow setting the cover image for the Chapter/Issue + /// + public bool EnableChapterCoverImage { get; set; } + #endregion + + // Need to handle the Genre/tags stuff + public bool EnableGenres { get; set; } = true; + public bool EnableTags { get; set; } = true; + + /// + /// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names. + /// + public bool FirstLastPeopleNaming { get; set; } + + /// + /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. + /// + public Dictionary AgeRatingMappings { get; set; } + + /// + /// A list of rules that allow mapping a genre/tag to another genre/tag + /// + public List FieldMappings { get; set; } + + /// + /// A list of overrides that will enable writing to locked fields + /// + public List Overrides { get; set; } + + /// + /// Do not allow any Genre/Tag in this list to be written to Kavita + /// + public List Blacklist { get; set; } + + /// + /// Only allow these Tags to be written to Kavita + /// + public List Whitelist { get; set; } + + /// + /// Which Roles to allow metadata downloading for + /// + public List PersonRoles { get; set; } +} diff --git a/API/Entities/Person/ChapterPeople.cs b/API/Entities/Person/ChapterPeople.cs index cc0802782..c6a08a7dd 100644 --- a/API/Entities/Person/ChapterPeople.cs +++ b/API/Entities/Person/ChapterPeople.cs @@ -1,6 +1,6 @@ using API.Entities.Enums; -namespace API.Entities; +namespace API.Entities.Person; public class ChapterPeople { @@ -10,5 +10,14 @@ public class ChapterPeople public int PersonId { get; set; } public virtual Person Person { get; set; } + /// + /// The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches + /// + public bool KavitaPlusConnection { get; set; } + /// + /// A weight that allows lower numbers to sort first + /// + public int OrderWeight { get; set; } + public required PersonRole Role { get; set; } } diff --git a/API/Entities/Person/Person.cs b/API/Entities/Person/Person.cs index ba40b5f82..ed57fd6d3 100644 --- a/API/Entities/Person/Person.cs +++ b/API/Entities/Person/Person.cs @@ -1,46 +1,44 @@ using System.Collections.Generic; -using API.Entities.Enums; using API.Entities.Interfaces; -using API.Entities.Metadata; -namespace API.Entities; +namespace API.Entities.Person; public class Person : IHasCoverImage { public int Id { get; set; } public required string Name { get; set; } public required string NormalizedName { get; set; } - - //public ICollection Aliases { get; set; } = default!; + public ICollection Aliases { get; set; } = []; public string? CoverImage { get; set; } public bool CoverImageLocked { get; set; } public string PrimaryColor { get; set; } public string SecondaryColor { get; set; } - public string Description { get; set; } - /// - /// ASIN for person - /// - /// Can be used for Amazon author lookup - public string? Asin { get; set; } + public string Description { get; set; } + /// + /// ASIN for person + /// + /// Can be used for Amazon author lookup + public string? Asin { get; set; } + + /// + /// https://anilist.co/staff/{AniListId}/ + /// + /// Kavita+ Only + public int AniListId { get; set; } = 0; + /// + /// https://myanimelist.net/people/{MalId}/ + /// https://myanimelist.net/character/{MalId}/CharacterName + /// + /// Kavita+ Only + public long MalId { get; set; } = 0; + /// + /// https://hardcover.app/authors/{HardcoverId} + /// + /// Kavita+ Only + public string? HardcoverId { get; set; } - /// - /// https://anilist.co/staff/{AniListId}/ - /// - /// Kavita+ Only - public int AniListId { get; set; } = 0; - /// - /// https://myanimelist.net/people/{MalId}/ - /// https://myanimelist.net/character/{MalId}/CharacterName - /// - /// Kavita+ Only - public long MalId { get; set; } = 0; - /// - /// https://hardcover.app/authors/{HardcoverId} - /// - /// Kavita+ Only - public string? HardcoverId { get; set; } /// /// https://metron.cloud/creator/{slug}/ /// @@ -48,8 +46,8 @@ public class Person : IHasCoverImage //public long MetronId { get; set; } = 0; // Relationships - public ICollection ChapterPeople { get; set; } = new List(); - public ICollection SeriesMetadataPeople { get; set; } = new List(); + public ICollection ChapterPeople { get; set; } = []; + public ICollection SeriesMetadataPeople { get; set; } = []; public void ResetColorScape() diff --git a/API/Entities/Person/PersonAlias.cs b/API/Entities/Person/PersonAlias.cs new file mode 100644 index 000000000..f053f608d --- /dev/null +++ b/API/Entities/Person/PersonAlias.cs @@ -0,0 +1,11 @@ +namespace API.Entities.Person; + +public class PersonAlias +{ + public int Id { get; set; } + public required string Alias { get; set; } + public required string NormalizedAlias { get; set; } + + public int PersonId { get; set; } + public Person Person { get; set; } +} diff --git a/API/Entities/Person/SeriesMetadataPeople.cs b/API/Entities/Person/SeriesMetadataPeople.cs index dd188ddf0..caea10cd6 100644 --- a/API/Entities/Person/SeriesMetadataPeople.cs +++ b/API/Entities/Person/SeriesMetadataPeople.cs @@ -1,7 +1,7 @@ using API.Entities.Enums; using API.Entities.Metadata; -namespace API.Entities; +namespace API.Entities.Person; public class SeriesMetadataPeople { @@ -11,5 +11,14 @@ public class SeriesMetadataPeople public int PersonId { get; set; } public virtual Person Person { get; set; } + /// + /// The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches + /// + public bool KavitaPlusConnection { get; set; } = false; + /// + /// A weight that allows lower numbers to sort first + /// + public int OrderWeight { get; set; } + public required PersonRole Role { get; set; } } diff --git a/API/Entities/Scrobble/ScrobbleEvent.cs b/API/Entities/Scrobble/ScrobbleEvent.cs index a02363992..8adfdcc2e 100644 --- a/API/Entities/Scrobble/ScrobbleEvent.cs +++ b/API/Entities/Scrobble/ScrobbleEvent.cs @@ -28,7 +28,7 @@ public class ScrobbleEvent : IEntityDate /// public string? ReviewBody { get; set; } public string? ReviewTitle { get; set; } - public required MediaFormat Format { get; set; } + public required PlusMediaFormat Format { get; set; } /// /// Depends on the ScrobbleEvent if filled in /// @@ -68,4 +68,14 @@ public class ScrobbleEvent : IEntityDate public DateTime LastModified { get; set; } public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } + + /// + /// Sets the ErrorDetail and marks the event as + /// + /// + public void SetErrorMessage(string errorMessage) + { + ErrorDetails = errorMessage; + IsErrored = true; + } } diff --git a/API/Entities/Scrobble/ScrobbleEventSortField.cs b/API/Entities/Scrobble/ScrobbleEventSortField.cs index 729ac7fbe..51b3a2146 100644 --- a/API/Entities/Scrobble/ScrobbleEventSortField.cs +++ b/API/Entities/Scrobble/ScrobbleEventSortField.cs @@ -7,5 +7,6 @@ public enum ScrobbleEventSortField LastModified = 2, Type= 3, Series = 4, - IsProcessed = 5 + IsProcessed = 5, + ScrobbleEventFilter = 6 } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index c467ee076..4f06ab0fc 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -103,6 +103,17 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage public int MaxHoursToRead { get; set; } public float AvgHoursToRead { get; set; } + #region KavitaPlus + /// + /// Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling. + /// + public bool DontMatch { get; set; } + /// + /// If the series was unable to match, it will be blacklisted until a manual metadata match overrides it + /// + public bool IsBlacklisted { get; set; } + #endregion + public SeriesMetadata Metadata { get; set; } = null!; public ExternalSeriesMetadata ExternalSeriesMetadata { get; set; } = null!; @@ -151,4 +162,14 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage PrimaryColor = string.Empty; SecondaryColor = string.Empty; } + + /// + /// Is this Series capable of Scrobbling + /// + /// This includes if there is no Match/Manual Match needed, the series is blacklisted, or has a NoMatch + /// + public bool WillScrobble() + { + return !IsBlacklisted && !DontMatch; + } } diff --git a/API/Entities/SideNavStreamType.cs b/API/Entities/SideNavStreamType.cs index 545c630d8..62f429889 100644 --- a/API/Entities/SideNavStreamType.cs +++ b/API/Entities/SideNavStreamType.cs @@ -10,5 +10,5 @@ public enum SideNavStreamType ExternalSource = 6, AllSeries = 7, WantToRead = 8, - BrowseAuthors = 9 + BrowsePeople = 9 } diff --git a/API/Extensions/AppUserExtensions.cs b/API/Extensions/AppUserExtensions.cs index 07b348c2d..be3d2c064 100644 --- a/API/Extensions/AppUserExtensions.cs +++ b/API/Extensions/AppUserExtensions.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using API.Data.Misc; using API.Entities; using API.Helpers; @@ -44,4 +45,13 @@ public static class AppUserExtensions OrderableHelper.ReorderItems(user.SideNavStreams); } + + public static AgeRestriction GetAgeRestriction(this AppUser user) + { + return new AgeRestriction() + { + AgeRating = user.AgeRestriction, + IncludeUnknowns = user.AgeRestrictionIncludeUnknowns, + }; + } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index b5c76b443..bd4783f25 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -12,6 +12,7 @@ using API.SignalR.Presence; using Kavita.Common; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -24,8 +25,6 @@ public static class ApplicationServiceExtensions { services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly); - //services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -52,8 +51,11 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -71,12 +73,15 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSqLite(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); @@ -84,12 +89,16 @@ public static class ApplicationServiceExtensions services.AddEasyCaching(options => { options.UseInMemory(EasyCacheProfiles.Favicon); - options.UseInMemory(EasyCacheProfiles.License); + options.UseInMemory(EasyCacheProfiles.Publisher); options.UseInMemory(EasyCacheProfiles.Library); options.UseInMemory(EasyCacheProfiles.RevokedJwt); + options.UseInMemory(EasyCacheProfiles.LocaleOptions); // KavitaPlus stuff options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries); + options.UseInMemory(EasyCacheProfiles.License); + options.UseInMemory(EasyCacheProfiles.LicenseInfo); + options.UseInMemory(EasyCacheProfiles.KavitaPlusMatchSeries); }); services.AddMemoryCache(options => @@ -114,6 +123,8 @@ public static class ApplicationServiceExtensions }); options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warnings => + warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); }); } } diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 4e84e2fa5..9bc06bab4 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using API.Data.Misc; +using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; namespace API.Extensions; #nullable enable @@ -42,4 +44,28 @@ public static class EnumerableExtensions return q; } + + public static IEnumerable RestrictAgainstAgeRestriction(this IEnumerable items, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return items; + var q = items.Where(s => s.AgeRating <= restriction.AgeRating); + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.AgeRating != AgeRating.Unknown); + } + + return q; + } + + public static IEnumerable RestrictAgainstAgeRestriction(this IEnumerable items, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return items; + var q = items.Where(s => s.AgeRating <= restriction.AgeRating); + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.AgeRating != AgeRating.Unknown); + } + + return q; + } } diff --git a/API/Extensions/FlurlExtensions.cs b/API/Extensions/FlurlExtensions.cs new file mode 100644 index 000000000..62d8543b6 --- /dev/null +++ b/API/Extensions/FlurlExtensions.cs @@ -0,0 +1,35 @@ +using System; +using Flurl.Http; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; + +namespace API.Extensions; +#nullable enable + +public static class FlurlExtensions +{ + public static IFlurlRequest WithKavitaPlusHeaders(this string request, string license, string? anilistToken = null) + { + return request + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-license-key", license) + .WithHeader("x-installId", HashUtil.ServerToken()) + .WithHeader("x-anilist-token", anilistToken ?? string.Empty) + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)); + } + + public static IFlurlRequest WithBasicHeaders(this string request, string apiKey) + { + return request + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", apiKey) + .WithHeader("x-installId", HashUtil.ServerToken()) + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)); + } +} diff --git a/API/Extensions/IHasKPlusMetadataExtensions.cs b/API/Extensions/IHasKPlusMetadataExtensions.cs new file mode 100644 index 000000000..84e35adc4 --- /dev/null +++ b/API/Extensions/IHasKPlusMetadataExtensions.cs @@ -0,0 +1,21 @@ +using API.Entities.Interfaces; +using API.Entities.MetadataMatching; + +namespace API.Extensions; + +public static class IHasKPlusMetadataExtensions +{ + + public static bool HasSetKPlusMetadata(this IHasKPlusMetadata hasKPlusMetadata, MetadataSettingField field) + { + return hasKPlusMetadata.KPlusOverrides.Contains(field); + } + + public static void AddKPlusOverride(this IHasKPlusMetadata hasKPlusMetadata, MetadataSettingField field) + { + if (hasKPlusMetadata.KPlusOverrides.Contains(field)) return; + + hasKPlusMetadata.KPlusOverrides.Add(field); + } + +} diff --git a/API/Extensions/ImageExtensions.cs b/API/Extensions/ImageExtensions.cs new file mode 100644 index 000000000..5779b18ec --- /dev/null +++ b/API/Extensions/ImageExtensions.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Image = SixLabors.ImageSharp.Image; + +namespace API.Extensions; + +public static class ImageExtensions +{ + + /// + /// Structure to hold various image quality metrics + /// + private sealed class ImageQualityMetrics + { + public int Width { get; set; } + public int Height { get; set; } + public bool IsColor { get; set; } + public double Colorfulness { get; set; } + public double Contrast { get; set; } + public double Sharpness { get; set; } + public double NoiseLevel { get; set; } + } + + + /// + /// Calculate a similarity score (0-1f) based on resolution difference and MSE. + /// + /// Path to first image + /// Path to the second image + /// Similarity score between 0-1, where 1 is identical + public static float CalculateSimilarity(this string imagePath1, string imagePath2) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Load both images as Rgba32 (consistent with the rest of the code) + using var img1 = Image.Load(imagePath1); + using var img2 = Image.Load(imagePath2); + + // Calculate resolution difference factor + var res1 = img1.Width * img1.Height; + var res2 = img2.Width * img2.Height; + var resolutionDiff = Math.Abs(res1 - res2) / (float) Math.Max(res1, res2); + + // Calculate mean squared error for pixel differences + var mse = img1.GetMeanSquaredError(img2); + + // Normalize MSE (65025 = 255², which is the max possible squared difference per channel) + var normalizedMse = 1f - Math.Min(1f, mse / 65025f); + + // Final similarity score (weighted average of resolution difference and color difference) + return Math.Max(0f, 1f - (resolutionDiff * 0.5f) - (1f - normalizedMse) * 0.5f); + } + + /// + /// Smaller is better + /// + /// + /// + /// + public static float GetMeanSquaredError(this Image img1, Image img2) + { + if (img1.Width != img2.Width || img1.Height != img2.Height) + { + img2.Mutate(x => x.Resize(img1.Width, img1.Height)); + } + + double totalDiff = 0; + for (var y = 0; y < img1.Height; y++) + { + for (var x = 0; x < img1.Width; x++) + { + var pixel1 = img1[x, y]; + var pixel2 = img2[x, y]; + + var diff = Math.Pow(pixel1.R - pixel2.R, 2) + + Math.Pow(pixel1.G - pixel2.G, 2) + + Math.Pow(pixel1.B - pixel2.B, 2); + totalDiff += diff; + } + } + + return (float) (totalDiff / (img1.Width * img1.Height)); + } + + /// + /// Determines which image is "better" based on multiple quality factors + /// using only the cross-platform ImageSharp library + /// + /// Path to first image + /// Path to the second image + /// Whether to prefer color images over grayscale (default: true) + /// The path of the better image + public static string GetBetterImage(this string imagePath1, string imagePath2, bool preferColor = true) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Quick metadata check to get width/height without loading full pixel data + var info1 = Image.Identify(imagePath1); + var info2 = Image.Identify(imagePath2); + + // Calculate resolution factor + double resolutionFactor1 = info1.Width * info1.Height; + double resolutionFactor2 = info2.Width * info2.Height; + + // If one image is significantly higher resolution (3x or more), just pick it + // This avoids fully loading both images when the choice is obvious + if (resolutionFactor1 > resolutionFactor2 * 3) + return imagePath1; + if (resolutionFactor2 > resolutionFactor1 * 3) + return imagePath2; + + // Otherwise, we need to analyze the actual image data for both + + // NOTE: We HAVE to use these scope blocks and load image here otherwise memory-mapped section exception will occur + ImageQualityMetrics metrics1; + using (var img1 = Image.Load(imagePath1)) + { + metrics1 = GetImageQualityMetrics(img1); + } + + ImageQualityMetrics metrics2; + using (var img2 = Image.Load(imagePath2)) + { + metrics2 = GetImageQualityMetrics(img2); + } + + + // If one is color, and one is grayscale, then we prefer color + if (preferColor && metrics1.IsColor != metrics2.IsColor) + { + return metrics1.IsColor ? imagePath1 : imagePath2; + } + + // Calculate overall quality scores + var score1 = CalculateOverallScore(metrics1); + var score2 = CalculateOverallScore(metrics2); + + return score1 >= score2 ? imagePath1 : imagePath2; + } + + + /// + /// Calculate a weighted overall score based on metrics + /// + private static double CalculateOverallScore(ImageQualityMetrics metrics) + { + // Resolution factor (normalized to HD resolution) + var resolutionFactor = Math.Min(1.0, (metrics.Width * metrics.Height) / (double) (1920 * 1080)); + + // Color factor + var colorFactor = metrics.IsColor ? (0.5 + 0.5 * metrics.Colorfulness) : 0.3; + + // Quality factors + var contrastFactor = Math.Min(1.0, metrics.Contrast); + var sharpnessFactor = Math.Min(1.0, metrics.Sharpness); + + // Noise penalty (less noise is better) + var noisePenalty = Math.Max(0, 1.0 - metrics.NoiseLevel); + + // Weighted combination + return (resolutionFactor * 0.35) + + (colorFactor * 0.3) + + (contrastFactor * 0.15) + + (sharpnessFactor * 0.15) + + (noisePenalty * 0.05); + } + + /// + /// Gets quality metrics for an image + /// + private static ImageQualityMetrics GetImageQualityMetrics(Image image) + { + // Create a smaller version if the image is large to speed up analysis + Image workingImage; + if (image.Width > 512 || image.Height > 512) + { + workingImage = image.Clone(ctx => ctx.Resize( + new ResizeOptions { + Size = new Size(512), + Mode = ResizeMode.Max + })); + } + else + { + workingImage = image.Clone(); + } + + var metrics = new ImageQualityMetrics + { + Width = image.Width, + Height = image.Height + }; + + // Color analysis (is the image color or grayscale?) + var colorInfo = AnalyzeColorfulness(workingImage); + metrics.IsColor = colorInfo.IsColor; + metrics.Colorfulness = colorInfo.Colorfulness; + + // Contrast analysis + metrics.Contrast = CalculateContrast(workingImage); + + // Sharpness estimation + metrics.Sharpness = EstimateSharpness(workingImage); + + // Noise estimation + metrics.NoiseLevel = EstimateNoiseLevel(workingImage); + + // Clean up + workingImage.Dispose(); + + return metrics; + } + + /// + /// Analyzes colorfulness of an image + /// + private static (bool IsColor, double Colorfulness) AnalyzeColorfulness(Image image) + { + // For performance, sample a subset of pixels + var sampleSize = Math.Min(1000, image.Width * image.Height); + var stepSize = Math.Max(1, (image.Width * image.Height) / sampleSize); + + var colorCount = 0; + List<(int R, int G, int B)> samples = []; + + // Sample pixels + for (var i = 0; i < image.Width * image.Height; i += stepSize) + { + var x = i % image.Width; + var y = i / image.Width; + + var pixel = image[x, y]; + + // Check if RGB channels differ by a threshold + // High difference indicates color, low difference indicates grayscale + var rMinusG = Math.Abs(pixel.R - pixel.G); + var rMinusB = Math.Abs(pixel.R - pixel.B); + var gMinusB = Math.Abs(pixel.G - pixel.B); + + if (rMinusG > 15 || rMinusB > 15 || gMinusB > 15) + { + colorCount++; + } + + samples.Add((pixel.R, pixel.G, pixel.B)); + } + + // Calculate colorfulness metric based on Hasler and Süsstrunk's approach + // This measures the spread and intensity of colors + if (samples.Count <= 0) return (false, 0); + + // Calculate rg and yb opponent channels + var rg = samples.Select(p => p.R - p.G).ToList(); + var yb = samples.Select(p => 0.5 * (p.R + p.G) - p.B).ToList(); + + // Calculate standard deviation and mean of opponent channels + var rgStdDev = CalculateStdDev(rg); + var ybStdDev = CalculateStdDev(yb); + var rgMean = rg.Average(); + var ybMean = yb.Average(); + + // Combine into colorfulness metric + var stdRoot = Math.Sqrt(rgStdDev * rgStdDev + ybStdDev * ybStdDev); + var meanRoot = Math.Sqrt(rgMean * rgMean + ybMean * ybMean); + + var colorfulness = stdRoot + 0.3 * meanRoot; + + // Normalize to 0-1 range (typical colorfulness is 0-100) + colorfulness = Math.Min(1.0, colorfulness / 100.0); + + var isColor = (double)colorCount / samples.Count > 0.05; + + return (isColor, colorfulness); + + } + + /// + /// Calculate standard deviation of a list of values + /// + private static double CalculateStdDev(List values) + { + var mean = values.Average(); + var sumOfSquaresOfDifferences = values.Select(val => (val - mean) * (val - mean)).Sum(); + return Math.Sqrt(sumOfSquaresOfDifferences / values.Count); + } + + /// + /// Calculate standard deviation of a list of values + /// + private static double CalculateStdDev(List values) + { + var mean = values.Average(); + var sumOfSquaresOfDifferences = values.Select(val => (val - mean) * (val - mean)).Sum(); + return Math.Sqrt(sumOfSquaresOfDifferences / values.Count); + } + + /// + /// Calculates contrast of an image + /// + private static double CalculateContrast(Image image) + { + // For performance, sample a subset of pixels + var sampleSize = Math.Min(1000, image.Width * image.Height); + var stepSize = Math.Max(1, (image.Width * image.Height) / sampleSize); + + List luminanceValues = new(); + + // Sample pixels and calculate luminance + for (var i = 0; i < image.Width * image.Height; i += stepSize) + { + var x = i % image.Width; + var y = i / image.Width; + + var pixel = image[x, y]; + + // Calculate luminance + var luminance = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B); + luminanceValues.Add(luminance); + } + + if (luminanceValues.Count < 2) + return 0; + + // Use RMS contrast (root-mean-square of pixel intensity) + var mean = luminanceValues.Average(); + var sumOfSquaresOfDifferences = luminanceValues.Sum(l => Math.Pow(l - mean, 2)); + var rmsContrast = Math.Sqrt(sumOfSquaresOfDifferences / luminanceValues.Count) / mean; + + // Normalize to 0-1 range + return Math.Min(1.0, rmsContrast); + } + + /// + /// Estimates sharpness using simple Laplacian-based method + /// + private static double EstimateSharpness(Image image) + { + // For simplicity, convert to grayscale + var grayImage = new int[image.Width, image.Height]; + + // Convert to grayscale + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < image.Width; x++) + { + var pixel = image[x, y]; + grayImage[x, y] = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B); + } + } + + // Apply Laplacian filter (3x3) + // The Laplacian measures local variations - higher values indicate edges/details + double laplacianSum = 0; + var validPixels = 0; + + // Laplacian kernel: [0, 1, 0, 1, -4, 1, 0, 1, 0] + for (var y = 1; y < image.Height - 1; y++) + { + for (var x = 1; x < image.Width - 1; x++) + { + var laplacian = + grayImage[x, y - 1] + + grayImage[x - 1, y] - 4 * grayImage[x, y] + grayImage[x + 1, y] + + grayImage[x, y + 1]; + + laplacianSum += Math.Abs(laplacian); + validPixels++; + } + } + + if (validPixels == 0) + return 0; + + // Calculate variance of Laplacian + var laplacianVariance = laplacianSum / validPixels; + + // Normalize to 0-1 range (typical values range from 0-1000) + return Math.Min(1.0, laplacianVariance / 1000.0); + } + + /// + /// Estimates noise level using simple block-based variance method + /// + private static double EstimateNoiseLevel(Image image) + { + // Block size for noise estimation + const int blockSize = 8; + List blockVariances = new(); + + // Calculate variance in small blocks throughout the image + for (var y = 0; y < image.Height - blockSize; y += blockSize) + { + for (var x = 0; x < image.Width - blockSize; x += blockSize) + { + List blockValues = new(); + + // Sample block + for (var by = 0; by < blockSize; by++) + { + for (var bx = 0; bx < blockSize; bx++) + { + var pixel = image[x + bx, y + by]; + var value = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B); + blockValues.Add(value); + } + } + + // Calculate variance of this block + var blockMean = blockValues.Average(); + var blockVariance = blockValues.Sum(v => Math.Pow(v - blockMean, 2)) / blockValues.Count; + blockVariances.Add(blockVariance); + } + } + + if (blockVariances.Count == 0) + return 0; + + // Sort block variances and take lowest 10% (likely uniform areas where noise is most visible) + blockVariances.Sort(); + var smoothBlocksCount = Math.Max(1, blockVariances.Count / 10); + var averageNoiseVariance = blockVariances.Take(smoothBlocksCount).Average(); + + // Normalize to 0-1 range (typical noise variances are 0-100) + return Math.Min(1.0, averageNoiseVariance / 100.0); + } +} diff --git a/API/Extensions/PlusMediaFormatExtensions.cs b/API/Extensions/PlusMediaFormatExtensions.cs new file mode 100644 index 000000000..a88b9c2f9 --- /dev/null +++ b/API/Extensions/PlusMediaFormatExtensions.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using API.DTOs.Scrobbling; +using API.Entities.Enums; + +namespace API.Extensions; + +public static class PlusMediaFormatExtensions +{ + public static PlusMediaFormat ConvertToPlusMediaFormat(this LibraryType libraryType, MangaFormat? seriesFormat = null) + { + + return libraryType switch + { + LibraryType.Manga => seriesFormat is MangaFormat.Epub ? PlusMediaFormat.LightNovel : PlusMediaFormat.Manga, + LibraryType.Comic => PlusMediaFormat.Comic, + LibraryType.LightNovel => PlusMediaFormat.LightNovel, + LibraryType.Book => PlusMediaFormat.LightNovel, + LibraryType.Image => PlusMediaFormat.Manga, + LibraryType.ComicVine => PlusMediaFormat.Comic, + _ => throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null) + }; + } + + public static IEnumerable ConvertToLibraryTypes(this PlusMediaFormat plusMediaFormat) + { + return plusMediaFormat switch + { + PlusMediaFormat.Manga => [LibraryType.Manga, LibraryType.Image], + PlusMediaFormat.Comic => [LibraryType.Comic, LibraryType.ComicVine], + PlusMediaFormat.LightNovel => [LibraryType.LightNovel, LibraryType.Book, LibraryType.Manga], + PlusMediaFormat.Book => [LibraryType.LightNovel, LibraryType.Book], + _ => throw new ArgumentOutOfRangeException(nameof(plusMediaFormat), plusMediaFormat, null) + }; + } + + public static IList GetMangaFormats(this PlusMediaFormat? mediaFormat) + { + return mediaFormat.HasValue ? mediaFormat.Value.GetMangaFormats() : [MangaFormat.Archive]; + } + + public static IList GetMangaFormats(this PlusMediaFormat mediaFormat) + { + return mediaFormat switch + { + PlusMediaFormat.Manga => [MangaFormat.Archive, MangaFormat.Image], + PlusMediaFormat.Comic => [MangaFormat.Archive], + PlusMediaFormat.LightNovel => [MangaFormat.Epub, MangaFormat.Pdf], + PlusMediaFormat.Book => [MangaFormat.Epub, MangaFormat.Pdf], + _ => [MangaFormat.Archive] + }; + } + + +} diff --git a/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs new file mode 100644 index 000000000..c36164d9d --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; +using API.Entities.Person; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions.Filtering; + +public static class PersonFilter +{ + public static IQueryable HasPersonName(this IQueryable queryable, bool condition, + FilterComparison comparison, string queryString) + { + if (string.IsNullOrEmpty(queryString) || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.Name.Equals(queryString)), + FilterComparison.BeginsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"{queryString}%")), + FilterComparison.EndsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}")), + FilterComparison.Matches => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}%")), + FilterComparison.NotEqual => queryable.Where(p => p.Name != queryString), + FilterComparison.NotContains or FilterComparison.GreaterThan or FilterComparison.GreaterThanEqual + or FilterComparison.LessThan or FilterComparison.LessThanEqual or FilterComparison.Contains + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast or FilterComparison.MustContains + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Name"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + public static IQueryable HasPersonRole(this IQueryable queryable, bool condition, + FilterComparison comparison, IList roles) + { + if (roles == null || roles.Count == 0 || !condition) return queryable; + + return comparison switch + { + FilterComparison.Contains or FilterComparison.MustContains => queryable.Where(p => + p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || + p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.NotContains => queryable.Where(p => + !p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) && + !p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.Equal or FilterComparison.NotEqual or FilterComparison.BeginsWith + or FilterComparison.EndsWith or FilterComparison.Matches or FilterComparison.GreaterThan + or FilterComparison.GreaterThanEqual or FilterComparison.LessThan or FilterComparison.LessThanEqual + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Role"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonSeriesCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.SeriesCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonChapterCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.ChapterCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } +} diff --git a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs index f6606026b..d7acf9381 100644 --- a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs @@ -1,10 +1,11 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Data.Misc; using API.Data.Repositories; using API.Entities; using API.Entities.Metadata; -using AutoMapper.QueryableExtensions; +using API.Entities.Person; using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions.Filtering; @@ -49,23 +50,26 @@ public static class SearchQueryableExtensions // Get people from SeriesMetadata var peopleFromSeriesMetadata = queryable .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.People) - .Where(p => p.Person.Name != null && EF.Functions.Like(p.Person.Name, $"%{searchQuery}%")) - .Select(p => p.Person); + .SelectMany(sm => sm.People.Select(sp => sp.Person)) + .Where(p => + EF.Functions.Like(p.Name, $"%{searchQuery}%") || + p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")) + ); - // Get people from ChapterPeople by navigating through Volume -> Series var peopleFromChapterPeople = queryable .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.Series.Volumes) .SelectMany(v => v.Chapters) - .SelectMany(ch => ch.People) - .Where(cp => cp.Person.Name != null && EF.Functions.Like(cp.Person.Name, $"%{searchQuery}%")) - .Select(cp => cp.Person); + .SelectMany(ch => ch.People.Select(cp => cp.Person)) + .Where(p => + EF.Functions.Like(p.Name, $"%{searchQuery}%") || + p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")) + ); // Combine both queries and ensure distinct results return peopleFromSeriesMetadata .Union(peopleFromChapterPeople) - .Distinct() + .Select(p => p) .OrderBy(p => p.NormalizedName); } diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index 822a859c5..ad51a4a62 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -252,8 +252,6 @@ public static class SeriesFilter if (!condition) return queryable; var subQuery = queryable - .Include(s => s.Progress) - .Where(s => s.Progress != null) .Select(s => new { SeriesId = s.Id, @@ -372,7 +370,7 @@ public static class SeriesFilter var subQuery = queryable .Include(s => s.Progress) - .Where(s => s.Progress != null) + .Where(s => s.Progress.Any()) .Select(s => new { SeriesId = s.Id, @@ -435,7 +433,7 @@ public static class SeriesFilter var subQuery = queryable .Include(s => s.Progress) - .Where(s => s.Progress != null) + .Where(s => s.Progress.Any()) .Select(s => new { SeriesId = s.Id, @@ -512,7 +510,7 @@ public static class SeriesFilter return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.IsEmpty: - return queryable.Where(s => s.Metadata.Tags == null || s.Metadata.Tags.Count == 0); + return queryable.Where(s => s.Metadata.Tags.Count == 0); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -709,7 +707,7 @@ public static class SeriesFilter return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.IsEmpty: - return queryable.Where(s => collectionSeries.All(c => c != s.Id)); + return queryable.Where(s => s.Collections.Count == 0); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 983f6798e..bfc585455 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -1,6 +1,7 @@ using System.Linq; using API.Data.Repositories; using API.Entities; +using API.Entities.Person; using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions; @@ -72,6 +73,18 @@ public static class IncludesExtensions .Include(c => c.Tags); } + if (includes.HasFlag(ChapterIncludes.ExternalReviews)) + { + queryable = queryable + .Include(c => c.ExternalReviews); + } + + if (includes.HasFlag(ChapterIncludes.ExternalRatings)) + { + queryable = queryable + .Include(c => c.ExternalRatings); + } + return queryable.AsSplitQuery(); } @@ -253,6 +266,11 @@ public static class IncludesExtensions .ThenInclude(c => c.Items); } + if (includeFlags.HasFlag(AppUserIncludes.ChapterRatings)) + { + query = query.Include(u => u.ChapterRatings); + } + return query.AsSplitQuery(); } @@ -303,4 +321,25 @@ public static class IncludesExtensions return query.AsSplitQuery(); } + + public static IQueryable Includes(this IQueryable queryable, PersonIncludes includeFlags) + { + + if (includeFlags.HasFlag(PersonIncludes.Aliases)) + { + queryable = queryable.Include(p => p.Aliases); + } + + if (includeFlags.HasFlag(PersonIncludes.ChapterPeople)) + { + queryable = queryable.Include(p => p.ChapterPeople); + } + + if (includeFlags.HasFlag(PersonIncludes.SeriesPeople)) + { + queryable = queryable.Include(p => p.SeriesMetadataPeople); + } + + return queryable; + } } diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index 571e9430c..ef2af721f 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -5,9 +5,13 @@ using System.Linq.Expressions; using System.Threading.Tasks; using API.Data.Misc; using API.Data.Repositories; +using API.DTOs; using API.DTOs.Filtering; +using API.DTOs.KavitaPlus.Manage; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Entities.Enums; +using API.Entities.Person; using API.Entities.Scrobble; using Microsoft.EntityFrameworkCore; @@ -254,6 +258,7 @@ public static class QueryableExtensions ScrobbleEventSortField.Type => query.OrderByDescending(s => s.ScrobbleEventType), ScrobbleEventSortField.Series => query.OrderByDescending(s => s.Series.NormalizedName), ScrobbleEventSortField.IsProcessed => query.OrderByDescending(s => s.IsProcessed), + ScrobbleEventSortField.ScrobbleEventFilter => query.OrderByDescending(s => s.ScrobbleEventType), _ => query }; } @@ -266,10 +271,32 @@ public static class QueryableExtensions ScrobbleEventSortField.Type => query.OrderBy(s => s.ScrobbleEventType), ScrobbleEventSortField.Series => query.OrderBy(s => s.Series.NormalizedName), ScrobbleEventSortField.IsProcessed => query.OrderBy(s => s.IsProcessed), + ScrobbleEventSortField.ScrobbleEventFilter => query.OrderBy(s => s.ScrobbleEventType), _ => query }; } + public static IQueryable SortBy(this IQueryable query, PersonSortOptions? sort) + { + if (sort == null) + { + return query.OrderBy(p => p.Name); + } + + return sort.SortField switch + { + PersonSortField.Name when sort.IsAscending => query.OrderBy(p => p.Name), + PersonSortField.Name => query.OrderByDescending(p => p.Name), + PersonSortField.SeriesCount when sort.IsAscending => query.OrderBy(p => p.SeriesMetadataPeople.Count), + PersonSortField.SeriesCount => query.OrderByDescending(p => p.SeriesMetadataPeople.Count), + PersonSortField.ChapterCount when sort.IsAscending => query.OrderBy(p => p.ChapterPeople.Count), + PersonSortField.ChapterCount => query.OrderByDescending(p => p.ChapterPeople.Count), + _ => query.OrderBy(p => p.Name) + }; + + + } + /// /// Performs either OrderBy or OrderByDescending on the given query based on the value of SortOptions.IsAscending. /// @@ -281,4 +308,21 @@ public static class QueryableExtensions { return sortOptions.IsAscending ? query.OrderBy(keySelector) : query.OrderByDescending(keySelector); } + + public static IQueryable FilterMatchState(this IQueryable query, MatchStateOption stateOption) + { + return stateOption switch + { + MatchStateOption.All => query, + MatchStateOption.Matched => query + .Include(s => s.ExternalSeriesMetadata) + .Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted), + MatchStateOption.NotMatched => query. + Include(s => s.ExternalSeriesMetadata) + .Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted && !s.DontMatch), + MatchStateOption.Error => query.Where(s => s.IsBlacklisted && !s.DontMatch), + MatchStateOption.DontMatch => query.Where(s => s.DontMatch), + _ => query + }; + } } diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index ebc233056..e0738bdf3 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -3,6 +3,8 @@ using System.Linq; using API.Data.Misc; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; +using API.Entities.Person; namespace API.Extensions.QueryExtensions; #nullable enable @@ -25,6 +27,20 @@ public static class RestrictByAgeExtensions return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(s => s.SeriesMetadata.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.SeriesMetadata.AgeRating != AgeRating.Unknown); + } + + return q; + } + + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -38,21 +54,20 @@ public static class RestrictByAgeExtensions return q; } - [Obsolete] - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(cp => cp.Chapter.Volume.Series.Metadata.AgeRating <= restriction.AgeRating); - if (restriction.IncludeUnknowns) + if (!restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return q.Where(cp => cp.Chapter.Volume.Series.Metadata.AgeRating != AgeRating.Unknown); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -67,18 +82,27 @@ public static class RestrictByAgeExtensions sm.Metadata.AgeRating <= restriction.AgeRating && sm.Metadata.AgeRating > AgeRating.Unknown)); } + /// + /// Returns all Genres where any of the linked Series/Chapters are less than or equal to restriction age rating + /// + /// + /// + /// public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) @@ -87,12 +111,15 @@ public static class RestrictByAgeExtensions if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) diff --git a/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs new file mode 100644 index 000000000..9ec1b8621 --- /dev/null +++ b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs @@ -0,0 +1,31 @@ +using System.Linq; +using API.Entities; +using API.Entities.Person; + +namespace API.Extensions.QueryExtensions; + +public static class RestrictByLibraryExtensions +{ + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(p => + p.ChapterPeople.Any(cp => userLibs.Contains(cp.Chapter.Volume.Series.LibraryId)) || + p.SeriesMetadataPeople.Any(sm => userLibs.Contains(sm.SeriesMetadata.Series.LibraryId))); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(cp => userLibs.Contains(cp.Volume.Series.LibraryId)); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(sm => userLibs.Contains(sm.SeriesMetadata.Series.LibraryId)); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(cp => userLibs.Contains(cp.Chapter.Volume.Series.LibraryId)); + } +} diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs index 138209e0d..28419921a 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -18,9 +18,9 @@ public static class StringExtensions // Remove all newline and control characters var sanitized = input - .Replace(Environment.NewLine, "") - .Replace("\n", "") - .Replace("\r", ""); + .Replace(Environment.NewLine, string.Empty) + .Replace("\n", string.Empty) + .Replace("\r", string.Empty); // Optionally remove other potentially unwanted characters sanitized = Regex.Replace(sanitized, @"[^\u0020-\u007E]", string.Empty); // Removes non-printable ASCII diff --git a/API/Extensions/VersionExtensions.cs b/API/Extensions/VersionExtensions.cs new file mode 100644 index 000000000..1877b48b1 --- /dev/null +++ b/API/Extensions/VersionExtensions.cs @@ -0,0 +1,17 @@ +using System; + +namespace API.Extensions; + +public static class VersionExtensions +{ + public static bool CompareWithoutRevision(this Version v1, Version v2) + { + if (v1.Major != v2.Major) + return v1.Major == v2.Major; + if (v1.Minor != v2.Minor) + return v1.Minor == v2.Minor; + if (v1.Build != v2.Build) + return v1.Build == v2.Build; + return true; + } +} diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 6c8ad418f..bb7511c64 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -8,10 +8,14 @@ using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Dashboard; using API.DTOs.Device; +using API.DTOs.Email; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.KavitaPlus.Manage; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.MediaErrors; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Progress; using API.DTOs.Reader; using API.DTOs.ReadingLists; @@ -26,12 +30,16 @@ using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Entities.Scrobble; using API.Extensions.QueryExtensions.Filtering; using API.Helpers.Converters; using API.Services; using AutoMapper; using CollectionTag = API.Entities.CollectionTag; +using EmailHistory = API.Entities.EmailHistory; +using ExternalSeriesMetadata = API.Entities.Metadata.ExternalSeriesMetadata; using MediaError = API.Entities.MediaError; using PublicationStatus = API.Entities.Enums.PublicationStatus; using SiteTheme = API.Entities.SiteTheme; @@ -61,7 +69,8 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)) .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); - CreateMap(); + CreateMap() + .ForMember(dest => dest.Aliases, opt => opt.MapFrom(src => src.Aliases.Select(s => s.Alias))); CreateMap(); CreateMap(); CreateMap(); @@ -90,6 +99,16 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.Username, opt => opt.MapFrom(src => src.AppUser.UserName)); + CreateMap() + .ForMember(dest => dest.LibraryId, + opt => + opt.MapFrom(src => src.Series.LibraryId)) + .ForMember(dest => dest.Body, + opt => + opt.MapFrom(src => src.Review)) + .ForMember(dest => dest.Username, + opt => + opt.MapFrom(src => src.AppUser.UserName)); CreateMap() .ForMember(dest => dest.PageNum, @@ -116,8 +135,8 @@ public class AutoMapperProfiles : Profile // Map Characters .ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People .Where(cp => cp.Role == PersonRole.Character) - .Select(cp => cp.Person) - .OrderBy(p => p.NormalizedName))) + .OrderBy(cp => cp.OrderWeight) + .Select(cp => cp.Person))) // Map Pencillers .ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People .Where(cp => cp.Role == PersonRole.Penciller) @@ -256,19 +275,19 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.Theme, opt => - opt.MapFrom(src => src.Theme)) + opt.MapFrom(src => src.Theme)); + + CreateMap() .ForMember(dest => dest.BookReaderThemeName, opt => - opt.MapFrom(src => src.BookThemeName)) - .ForMember(dest => dest.BookReaderLayoutMode, - opt => - opt.MapFrom(src => src.BookReaderLayoutMode)); + opt.MapFrom(src => src.BookThemeName)); CreateMap(); CreateMap() - .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)) + .ForMember(dest => dest.OwnerUserName, opt => opt.MapFrom(src => src.AppUser.UserName)); CreateMap(); CreateMap(); CreateMap(); @@ -331,17 +350,43 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.BodyJustText, opt => - opt.MapFrom(src => ReviewService.GetCharacters(src.Body))); + opt.MapFrom(src => ReviewHelper.GetCharacters(src.Body))); CreateMap(); + CreateMap() + .ForMember(dest => dest.Series, + opt => + opt.MapFrom(src => src)) + .ForMember(dest => dest.IsMatched, + opt => + opt.MapFrom(src => src.ExternalSeriesMetadata != null && src.ExternalSeriesMetadata.AniListId != 0 + && src.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue)) + .ForMember(dest => dest.ValidUntilUtc, + opt => opt.MapFrom(src => + src.ExternalSeriesMetadata != null + ? src.ExternalSeriesMetadata.ValidUntilUtc + : DateTime.MinValue)); CreateMap(); + CreateMap() + .ForMember(dest => dest.ToUserName, opt => opt.MapFrom(src => src.AppUser.UserName)); CreateMap() .ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.Volume.SeriesId)) .ForMember(dest => dest.VolumeTitle, opt => opt.MapFrom(src => src.Volume.Name)) .ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId)) .ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type)); + + CreateMap(); + + CreateMap() + .ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List())) + .ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List())) + .ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List())) + .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary())); + + + } } diff --git a/API/Helpers/BookSortTitlePrefixHelper.cs b/API/Helpers/BookSortTitlePrefixHelper.cs new file mode 100644 index 000000000..c92df5d65 --- /dev/null +++ b/API/Helpers/BookSortTitlePrefixHelper.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace API.Helpers; + +/// +/// Responsible for parsing book titles "The man on the street" and removing the prefix -> "man on the street". +/// +/// This code is performance sensitive +public static class BookSortTitlePrefixHelper +{ + private static readonly Dictionary PrefixLookup; + private static readonly Dictionary> PrefixesByFirstChar; + + static BookSortTitlePrefixHelper() + { + var prefixes = new[] + { + // English + "the", "a", "an", + // Spanish + "el", "la", "los", "las", "un", "una", "unos", "unas", + // French + "le", "la", "les", "un", "une", "des", + // German + "der", "die", "das", "den", "dem", "ein", "eine", "einen", "einer", + // Italian + "il", "lo", "la", "gli", "le", "un", "uno", "una", + // Portuguese + "o", "a", "os", "as", "um", "uma", "uns", "umas", + // Russian (transliterated common ones) + "в", "на", "с", "к", "от", "для", + }; + + // Build lookup structures + PrefixLookup = new Dictionary(prefixes.Length, StringComparer.OrdinalIgnoreCase); + PrefixesByFirstChar = new Dictionary>(); + + foreach (var prefix in prefixes) + { + PrefixLookup[prefix] = 1; + + var firstChar = char.ToLowerInvariant(prefix[0]); + if (!PrefixesByFirstChar.TryGetValue(firstChar, out var list)) + { + list = []; + PrefixesByFirstChar[firstChar] = list; + } + list.Add(prefix); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ReadOnlySpan GetSortTitle(ReadOnlySpan title) + { + if (title.IsEmpty) return title; + + // Fast detection of script type by first character + var firstChar = title[0]; + + // CJK Unicode ranges - no processing needed for most cases + if ((firstChar >= 0x4E00 && firstChar <= 0x9FFF) || // CJK Unified + (firstChar >= 0x3040 && firstChar <= 0x309F) || // Hiragana + (firstChar >= 0x30A0 && firstChar <= 0x30FF)) // Katakana + { + return title; + } + + var firstSpaceIndex = title.IndexOf(' '); + if (firstSpaceIndex <= 0) return title; + + var potentialPrefix = title.Slice(0, firstSpaceIndex); + + // Fast path: check if first character could match any prefix + firstChar = char.ToLowerInvariant(potentialPrefix[0]); + if (!PrefixesByFirstChar.ContainsKey(firstChar)) + return title; + + // Only do the expensive lookup if first character matches + if (PrefixLookup.ContainsKey(potentialPrefix.ToString())) + { + var remainder = title.Slice(firstSpaceIndex + 1); + return remainder.IsEmpty ? title : remainder; + } + + return title; + } + + /// + /// Removes the sort prefix + /// + /// + /// + public static string GetSortTitle(string title) + { + var result = GetSortTitle(title.AsSpan()); + + return result.ToString(); + } +} diff --git a/API/Helpers/Builders/AppUserBuilder.cs b/API/Helpers/Builders/AppUserBuilder.cs index bc044c301..7ffac355e 100644 --- a/API/Helpers/Builders/AppUserBuilder.cs +++ b/API/Helpers/Builders/AppUserBuilder.cs @@ -21,7 +21,7 @@ public class AppUserBuilder : IEntityBuilder ApiKey = HashUtil.ApiKey(), UserPreferences = new AppUserPreferences { - Theme = theme ?? Seed.DefaultThemes.First() + Theme = theme ?? Seed.DefaultThemes.First(), }, ReadingLists = new List(), Bookmarks = new List(), @@ -31,7 +31,8 @@ public class AppUserBuilder : IEntityBuilder Devices = new List(), Id = 0, DashboardStreams = new List(), - SideNavStreams = new List() + SideNavStreams = new List(), + ReadingProfiles = [], }; } @@ -61,4 +62,10 @@ public class AppUserBuilder : IEntityBuilder return this; } + public AppUserBuilder WithRole(string role) + { + _appUser.UserRoles ??= new List(); + _appUser.UserRoles.Add(new AppUserRole() {Role = new AppRole() {Name = role}}); + return this; + } } diff --git a/API/Helpers/Builders/AppUserChapterRatingBuilder.cs b/API/Helpers/Builders/AppUserChapterRatingBuilder.cs new file mode 100644 index 000000000..b5deb9228 --- /dev/null +++ b/API/Helpers/Builders/AppUserChapterRatingBuilder.cs @@ -0,0 +1,40 @@ +#nullable enable +using System; +using API.Entities; + +namespace API.Helpers.Builders; + +public class ChapterRatingBuilder : IEntityBuilder +{ + private readonly AppUserChapterRating _rating; + public AppUserChapterRating Build() => _rating; + + public ChapterRatingBuilder(AppUserChapterRating? rating = null) + { + _rating = rating ?? new AppUserChapterRating(); + } + + public ChapterRatingBuilder WithSeriesId(int seriesId) + { + _rating.SeriesId = seriesId; + return this; + } + + public ChapterRatingBuilder WithChapterId(int chapterId) + { + _rating.ChapterId = chapterId; + return this; + } + + public ChapterRatingBuilder WithRating(int rating) + { + _rating.Rating = Math.Clamp(rating, 0, 5); + return this; + } + + public ChapterRatingBuilder WithBody(string body) + { + _rating.Review = body; + return this; + } +} diff --git a/API/Helpers/Builders/AppUserReadingProfileBuilder.cs b/API/Helpers/Builders/AppUserReadingProfileBuilder.cs new file mode 100644 index 000000000..26da5fd86 --- /dev/null +++ b/API/Helpers/Builders/AppUserReadingProfileBuilder.cs @@ -0,0 +1,54 @@ +using API.Entities; +using API.Entities.Enums; +using API.Extensions; + +namespace API.Helpers.Builders; + +public class AppUserReadingProfileBuilder +{ + private readonly AppUserReadingProfile _profile; + + public AppUserReadingProfile Build() => _profile; + + /// + /// The profile's kind will be unless overwritten with + /// + /// + public AppUserReadingProfileBuilder(int userId) + { + _profile = new AppUserReadingProfile + { + AppUserId = userId, + Kind = ReadingProfileKind.User, + SeriesIds = [], + LibraryIds = [] + }; + } + + public AppUserReadingProfileBuilder WithSeries(Series series) + { + _profile.SeriesIds.Add(series.Id); + return this; + } + + public AppUserReadingProfileBuilder WithLibrary(Library library) + { + _profile.LibraryIds.Add(library.Id); + return this; + } + + public AppUserReadingProfileBuilder WithKind(ReadingProfileKind kind) + { + _profile.Kind = kind; + return this; + } + + public AppUserReadingProfileBuilder WithName(string name) + { + _profile.Name = name; + _profile.NormalizedName = name.ToNormalized(); + return this; + } + + +} diff --git a/API/Helpers/Builders/ChapterBuilder.cs b/API/Helpers/Builders/ChapterBuilder.cs index 4d09a7abf..d9976d92a 100644 --- a/API/Helpers/Builders/ChapterBuilder.cs +++ b/API/Helpers/Builders/ChapterBuilder.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using API.Entities; using API.Entities.Enums; +using API.Entities.Person; using API.Services.Tasks.Scanner.Parser; namespace API.Helpers.Builders; @@ -24,7 +25,7 @@ public class ChapterBuilder : IEntityBuilder MinNumber = Parser.MinNumberFromRange(number), MaxNumber = Parser.MaxNumberFromRange(number), SortOrder = Parser.MinNumberFromRange(number), - Files = new List(), + Files = [], Pages = 1, CreatedUtc = DateTime.UtcNow }; @@ -38,9 +39,9 @@ public class ChapterBuilder : IEntityBuilder return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters)!) .WithRange(specialTreatment ? info.Filename : info.Chapters) - .WithTitle((specialTreatment && info.Format == MangaFormat.Epub) + .WithTitle(specialTreatment && info.Format is MangaFormat.Epub or MangaFormat.Pdf ? info.Title - : specialTitle) + : specialTitle ?? string.Empty) .WithIsSpecial(specialTreatment); } @@ -155,4 +156,24 @@ public class ChapterBuilder : IEntityBuilder return this; } + + public ChapterBuilder WithTags(IList tags) + { + _chapter.Tags ??= []; + foreach (var tag in tags) + { + _chapter.Tags.Add(tag); + } + return this; + } + + public ChapterBuilder WithGenres(IList genres) + { + _chapter.Genres ??= []; + foreach (var genre in genres) + { + _chapter.Genres.Add(genre); + } + return this; + } } diff --git a/API/Helpers/Builders/GenreBuilder.cs b/API/Helpers/Builders/GenreBuilder.cs index 69e68f6c1..9b2f1590e 100644 --- a/API/Helpers/Builders/GenreBuilder.cs +++ b/API/Helpers/Builders/GenreBuilder.cs @@ -16,14 +16,14 @@ public class GenreBuilder : IEntityBuilder { Title = name.Trim().SentenceCase(), NormalizedTitle = name.ToNormalized(), - Chapters = new List(), - SeriesMetadatas = new List() + Chapters = [], + SeriesMetadatas = [] }; } public GenreBuilder WithSeriesMetadata(SeriesMetadata seriesMetadata) { - _genre.SeriesMetadatas ??= new List(); + _genre.SeriesMetadatas ??= []; _genre.SeriesMetadatas.Add(seriesMetadata); return this; } diff --git a/API/Helpers/Builders/KoreaderBookDtoBuilder.cs b/API/Helpers/Builders/KoreaderBookDtoBuilder.cs new file mode 100644 index 000000000..debbe0347 --- /dev/null +++ b/API/Helpers/Builders/KoreaderBookDtoBuilder.cs @@ -0,0 +1,46 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using API.DTOs.Koreader; + +namespace API.Helpers.Builders; + +public class KoreaderBookDtoBuilder : IEntityBuilder +{ + private readonly KoreaderBookDto _dto; + public KoreaderBookDto Build() => _dto; + + public KoreaderBookDtoBuilder(string documentHash) + { + _dto = new KoreaderBookDto() + { + Document = documentHash, + Device = "Kavita" + }; + } + + public KoreaderBookDtoBuilder WithDocument(string documentHash) + { + _dto.Document = documentHash; + return this; + } + + public KoreaderBookDtoBuilder WithProgress(string progress) + { + _dto.Progress = progress; + return this; + } + + public KoreaderBookDtoBuilder WithPercentage(int? pageNum, int pages) + { + _dto.Percentage = (pageNum ?? 0) / (float) pages; + return this; + } + + public KoreaderBookDtoBuilder WithDeviceId(string installId, int userId) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(installId + userId)); + _dto.Device_id = Convert.ToHexString(hash); + return this; + } +} diff --git a/API/Helpers/Builders/LibraryBuilder.cs b/API/Helpers/Builders/LibraryBuilder.cs index 5550cfd51..950c5d3d2 100644 --- a/API/Helpers/Builders/LibraryBuilder.cs +++ b/API/Helpers/Builders/LibraryBuilder.cs @@ -104,7 +104,19 @@ public class LibraryBuilder : IEntityBuilder return this; } - public LibraryBuilder WIthAllowScrobbling(bool allowScrobbling) + public LibraryBuilder WithAllowMetadataMatching(bool allow) + { + _library.AllowMetadataMatching = allow; + return this; + } + + public LibraryBuilder WithEnableMetadata(bool enable) + { + _library.EnableMetadata = enable; + return this; + } + + public LibraryBuilder WithAllowScrobbling(bool allowScrobbling) { _library.AllowScrobbling = allowScrobbling; return this; diff --git a/API/Helpers/Builders/MangaFileBuilder.cs b/API/Helpers/Builders/MangaFileBuilder.cs index 5387a3349..ea3ff0c6d 100644 --- a/API/Helpers/Builders/MangaFileBuilder.cs +++ b/API/Helpers/Builders/MangaFileBuilder.cs @@ -60,4 +60,17 @@ public class MangaFileBuilder : IEntityBuilder _mangaFile.Id = Math.Max(id, 0); return this; } + + /// + /// Generate the Hash on the underlying file + /// + /// Only applicable to Epubs + public MangaFileBuilder WithHash() + { + if (_mangaFile.Format != MangaFormat.Epub) return this; + + _mangaFile.KoreaderHash = KoreaderHelper.HashContents(_mangaFile.FilePath); + + return this; + } } diff --git a/API/Helpers/Builders/MediaErrorBuilder.cs b/API/Helpers/Builders/MediaErrorBuilder.cs index 56b19ba33..4d0f7f3a0 100644 --- a/API/Helpers/Builders/MediaErrorBuilder.cs +++ b/API/Helpers/Builders/MediaErrorBuilder.cs @@ -1,5 +1,6 @@ using System.IO; using API.Entities; +using API.Services.Tasks.Scanner.Parser; namespace API.Helpers.Builders; @@ -12,7 +13,7 @@ public class MediaErrorBuilder : IEntityBuilder { _mediaError = new MediaError() { - FilePath = filePath, + FilePath = Parser.NormalizePath(filePath), Extension = Path.GetExtension(filePath).Replace(".", string.Empty).ToUpperInvariant() }; } diff --git a/API/Helpers/Builders/PersonAliasBuilder.cs b/API/Helpers/Builders/PersonAliasBuilder.cs new file mode 100644 index 000000000..e54ea8975 --- /dev/null +++ b/API/Helpers/Builders/PersonAliasBuilder.cs @@ -0,0 +1,19 @@ +using API.Entities.Person; +using API.Extensions; + +namespace API.Helpers.Builders; + +public class PersonAliasBuilder : IEntityBuilder +{ + private readonly PersonAlias _alias; + public PersonAlias Build() => _alias; + + public PersonAliasBuilder(string name) + { + _alias = new PersonAlias() + { + Alias = name.Trim(), + NormalizedAlias = name.ToNormalized(), + }; + } +} diff --git a/API/Helpers/Builders/PersonBuilder.cs b/API/Helpers/Builders/PersonBuilder.cs index 2bbdfa744..afd0c84af 100644 --- a/API/Helpers/Builders/PersonBuilder.cs +++ b/API/Helpers/Builders/PersonBuilder.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; -using API.Entities; -using API.Entities.Enums; -using API.Entities.Metadata; +using System.Linq; +using API.Entities.Person; using API.Extensions; namespace API.Helpers.Builders; @@ -33,6 +32,20 @@ public class PersonBuilder : IEntityBuilder return this; } + public PersonBuilder WithAlias(string alias) + { + if (_person.Aliases.Any(a => a.NormalizedAlias.Equals(alias.ToNormalized()))) + { + return this; + } + + _person.Aliases.Add(new PersonAliasBuilder(alias).Build()); + + return this; + } + + + public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople) { _person.SeriesMetadataPeople.Add(seriesMetadataPeople); diff --git a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs index 6a8e70bde..3da217b9f 100644 --- a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs +++ b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs @@ -2,14 +2,15 @@ using API.DTOs; using API.DTOs.Scrobbling; using API.Entities; +using API.Extensions; using API.Services.Plus; namespace API.Helpers.Builders; -public class PlusSeriesDtoBuilder : IEntityBuilder +public class PlusSeriesDtoBuilder : IEntityBuilder { - private readonly PlusSeriesDto _seriesDto; - public PlusSeriesDto Build() => _seriesDto; + private readonly PlusSeriesRequestDto _seriesRequestDto; + public PlusSeriesRequestDto Build() => _seriesRequestDto; /// /// This must be a FULL Series @@ -17,9 +18,9 @@ public class PlusSeriesDtoBuilder : IEntityBuilder /// public PlusSeriesDtoBuilder(Series series) { - _seriesDto = new PlusSeriesDto() + _seriesRequestDto = new PlusSeriesRequestDto() { - MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), + MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), SeriesName = series.Name, AltSeriesName = series.LocalizedName, AniListId = ScrobblingService.ExtractId(series.Metadata.WebLinks, diff --git a/API/Helpers/Builders/SeriesBuilder.cs b/API/Helpers/Builders/SeriesBuilder.cs index 525b0cddc..96e820659 100644 --- a/API/Helpers/Builders/SeriesBuilder.cs +++ b/API/Helpers/Builders/SeriesBuilder.cs @@ -21,11 +21,13 @@ public class SeriesBuilder : IEntityBuilder _series = new Series() { Name = name, + LocalizedName = name.ToNormalized(), + NormalizedLocalizedName = name.ToNormalized(), + OriginalName = name, SortName = name, NormalizedName = name.ToNormalized(), - NormalizedLocalizedName = name.ToNormalized(), Metadata = new SeriesMetadataBuilder() .WithPublicationStatus(PublicationStatus.OnGoing) .Build(), @@ -39,14 +41,25 @@ public class SeriesBuilder : IEntityBuilder /// /// /// - public SeriesBuilder WithLocalizedName(string localizedName) + public SeriesBuilder WithLocalizedName(string localizedName, bool lockStatus = false) { + // Why is this here? if (string.IsNullOrEmpty(localizedName)) { localizedName = _series.Name; } + _series.LocalizedName = localizedName; _series.NormalizedLocalizedName = localizedName.ToNormalized(); + _series.LocalizedNameLocked = lockStatus; + return this; + } + + public SeriesBuilder WithLocalizedNameAllowEmpty(string localizedName, bool lockStatus = false) + { + _series.LocalizedName = localizedName; + _series.NormalizedLocalizedName = localizedName.ToNormalized(); + _series.LocalizedNameLocked = lockStatus; return this; } @@ -106,4 +119,15 @@ public class SeriesBuilder : IEntityBuilder } + public SeriesBuilder WithRelationship(int targetSeriesId, RelationKind kind) + { + _series.Relations ??= []; + _series.Relations.Add(new SeriesRelation() + { + RelationKind = kind, + TargetSeriesId = targetSeriesId + }); + + return this; + } } diff --git a/API/Helpers/Builders/SeriesMetadataBuilder.cs b/API/Helpers/Builders/SeriesMetadataBuilder.cs index 316eaaf83..462bc4455 100644 --- a/API/Helpers/Builders/SeriesMetadataBuilder.cs +++ b/API/Helpers/Builders/SeriesMetadataBuilder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; namespace API.Helpers.Builders; @@ -39,15 +40,17 @@ public class SeriesMetadataBuilder : IEntityBuilder return this; } - public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status) + public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status, bool lockState = false) { _seriesMetadata.PublicationStatus = status; + _seriesMetadata.PublicationStatusLocked = lockState; return this; } - public SeriesMetadataBuilder WithAgeRating(AgeRating rating) + public SeriesMetadataBuilder WithAgeRating(AgeRating rating, bool lockState = false) { _seriesMetadata.AgeRating = rating; + _seriesMetadata.AgeRatingLocked = lockState; return this; } @@ -60,7 +63,6 @@ public class SeriesMetadataBuilder : IEntityBuilder Person = person, SeriesMetadata = _seriesMetadata, }); - return this; } @@ -70,15 +72,59 @@ public class SeriesMetadataBuilder : IEntityBuilder return this; } - public SeriesMetadataBuilder WithReleaseYear(int year) + public SeriesMetadataBuilder WithReleaseYear(int year, bool lockStatus = false) { _seriesMetadata.ReleaseYear = year; + _seriesMetadata.ReleaseYearLocked = lockStatus; return this; } - public SeriesMetadataBuilder WithSummary(string summary) + public SeriesMetadataBuilder WithSummary(string summary, bool lockStatus = false) { _seriesMetadata.Summary = summary; + _seriesMetadata.SummaryLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithGenre(Genre genre, bool lockStatus = false) + { + _seriesMetadata.Genres ??= []; + _seriesMetadata.Genres.Add(genre); + _seriesMetadata.GenresLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithGenres(List genres, bool lockStatus = false) + { + _seriesMetadata.Genres = genres; + _seriesMetadata.GenresLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithTag(Tag tag, bool lockStatus = false) + { + _seriesMetadata.Tags ??= []; + _seriesMetadata.Tags.Add(tag); + _seriesMetadata.TagsLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithTags(List tags, bool lockStatus = false) + { + _seriesMetadata.Tags = tags; + _seriesMetadata.TagsLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithMaxCount(int count) + { + _seriesMetadata.MaxCount = count; + return this; + } + + public SeriesMetadataBuilder WithTotalCount(int count) + { + _seriesMetadata.TotalCount = count; return this; } } diff --git a/API/Helpers/Builders/TagBuilder.cs b/API/Helpers/Builders/TagBuilder.cs index 084171f54..623587fd1 100644 --- a/API/Helpers/Builders/TagBuilder.cs +++ b/API/Helpers/Builders/TagBuilder.cs @@ -16,8 +16,8 @@ public class TagBuilder : IEntityBuilder { Title = name.Trim().SentenceCase(), NormalizedTitle = name.ToNormalized(), - Chapters = new List(), - SeriesMetadatas = new List() + Chapters = [], + SeriesMetadatas = [] }; } diff --git a/API/Helpers/Converters/FilterFieldValueConverter.cs b/API/Helpers/Converters/FilterFieldValueConverter.cs index b0fb8fd0f..631332f5f 100644 --- a/API/Helpers/Converters/FilterFieldValueConverter.cs +++ b/API/Helpers/Converters/FilterFieldValueConverter.cs @@ -100,7 +100,7 @@ public static class FilterFieldValueConverter .ToList(), FilterField.WantToRead => bool.Parse(value), FilterField.ReadProgress => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(), - FilterField.ReadingDate => DateTime.Parse(value), + FilterField.ReadingDate => DateTime.Parse(value, CultureInfo.InvariantCulture), FilterField.ReadLast => int.Parse(value), FilterField.Formats => value.Split(',') .Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x)) diff --git a/API/Helpers/Converters/PersonFilterFieldValueConverter.cs b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs new file mode 100644 index 000000000..822ce105a --- /dev/null +++ b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; + +namespace API.Helpers.Converters; + +public static class PersonFilterFieldValueConverter +{ + public static object ConvertValue(PersonFilterField field, string value) + { + return field switch + { + PersonFilterField.Name => value, + PersonFilterField.Role => ParsePersonRoles(value), + PersonFilterField.SeriesCount => int.Parse(value), + PersonFilterField.ChapterCount => int.Parse(value), + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported") + }; + } + + private static IList ParsePersonRoles(string value) + { + if (string.IsNullOrEmpty(value)) return []; + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(v => Enum.Parse(v.Trim())) + .ToList(); + } +} diff --git a/API/Helpers/DayOfWeekHelper.cs b/API/Helpers/DayOfWeekHelper.cs new file mode 100644 index 000000000..10cdb4170 --- /dev/null +++ b/API/Helpers/DayOfWeekHelper.cs @@ -0,0 +1,18 @@ +using System; + +namespace API.Helpers; + +public static class DayOfWeekHelper +{ + private static readonly Random Rnd = new(); + + /// + /// Returns a random DayOfWeek value. + /// + /// A randomly selected DayOfWeek. + public static DayOfWeek Random() + { + var values = Enum.GetValues(); + return (DayOfWeek)values.GetValue(Rnd.Next(values.Length))!; + } +} diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index b11915053..8580178d9 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -12,12 +12,19 @@ using Microsoft.EntityFrameworkCore; namespace API.Helpers; #nullable enable + public static class GenreHelper { + public static async Task UpdateChapterGenres(Chapter chapter, IEnumerable genreNames, IUnitOfWork unitOfWork) { // Normalize genre names once and store them in a hash set for quick lookups - var normalizedGenresToAdd = new HashSet(genreNames.Select(g => g.ToNormalized())); + var normalizedToOriginal = genreNames + .Select(g => new { Original = g, Normalized = g.ToNormalized() }) + .GroupBy(x => x.Normalized) + .ToDictionary(g => g.Key, g => g.First().Original); + + var normalizedGenresToAdd = new HashSet(normalizedToOriginal.Keys); // Remove genres that are no longer in the new list var genresToRemove = chapter.Genres @@ -40,7 +47,7 @@ public static class GenreHelper // Find missing genres that are not in the database var missingGenres = normalizedGenresToAdd .Where(nt => !existingGenreTitles.ContainsKey(nt)) - .Select(title => new GenreBuilder(title).Build()) + .Select(nt => new GenreBuilder(normalizedToOriginal[nt]).Build()) .ToList(); // Add missing genres to the database @@ -71,13 +78,19 @@ public static class GenreHelper public static void UpdateGenreList(ICollection? existingGenres, Series series, IReadOnlyCollection newGenres, Action handleAdd, Action onModified) + { + UpdateGenreList(existingGenres.DefaultIfEmpty().Select(t => t.Title).ToList(), series, newGenres, handleAdd, onModified); + } + + public static void UpdateGenreList(ICollection? existingGenres, Series series, + IReadOnlyCollection newGenres, Action handleAdd, Action onModified) { if (existingGenres == null) return; var isModified = false; // Convert tags and existing genres to hash sets for quick lookups by normalized title - var tagSet = new HashSet(existingGenres.Select(t => t.Title.ToNormalized())); + var tagSet = new HashSet(existingGenres.Select(t => t.ToNormalized())); var genreSet = new HashSet(series.Metadata.Genres.Select(g => g.NormalizedTitle)); // Remove tags that are no longer present in the input tags @@ -97,7 +110,7 @@ public static class GenreHelper // Add new tags from the input list foreach (var tagDto in existingGenres) { - var normalizedTitle = tagDto.Title.ToNormalized(); + var normalizedTitle = tagDto.ToNormalized(); if (genreSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres @@ -107,7 +120,7 @@ public static class GenreHelper } else { - handleAdd(new GenreBuilder(tagDto.Title).Build()); // Add new genre if not found + handleAdd(new GenreBuilder(tagDto).Build()); // Add new genre if not found } isModified = true; } diff --git a/API/Helpers/JwtHelper.cs b/API/Helpers/JwtHelper.cs new file mode 100644 index 000000000..0f9219804 --- /dev/null +++ b/API/Helpers/JwtHelper.cs @@ -0,0 +1,45 @@ +using System; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; + +namespace API.Helpers; + +public static class JwtHelper +{ + /// + /// Extracts the expiration date from a JWT token. + /// + public static DateTime GetTokenExpiry(string jwtToken) + { + if (string.IsNullOrEmpty(jwtToken)) + return DateTime.MinValue; + + // Parse the JWT and extract the expiry claim + var jwtHandler = new JwtSecurityTokenHandler(); + var token = jwtHandler.ReadJwtToken(jwtToken); + return token.ValidTo; + + // var exp = token.Claims.FirstOrDefault(c => c.Type == "exp")?.Value; + // + // if (long.TryParse(exp, CultureInfo.InvariantCulture, out var expSeconds)) + // { + // return DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcDateTime; + // } + // + // + // + // return DateTime.MinValue; + } + + /// + /// Checks if a JWT token is valid based on its expiry date. + /// + public static bool IsTokenValid(string jwtToken) + { + if (string.IsNullOrEmpty(jwtToken)) return false; + + var expiry = GetTokenExpiry(jwtToken); + return expiry > DateTime.UtcNow; + } +} diff --git a/API/Helpers/KoreaderHelper.cs b/API/Helpers/KoreaderHelper.cs new file mode 100644 index 000000000..e779cd911 --- /dev/null +++ b/API/Helpers/KoreaderHelper.cs @@ -0,0 +1,113 @@ +using API.DTOs.Progress; +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using API.Services.Tasks.Scanner.Parser; + +namespace API.Helpers; + +/// +/// All things related to Koreader +/// +/// Original developer: https://github.com/MFDeAngelo +public static class KoreaderHelper +{ + /// + /// Hashes the document according to a custom Koreader hashing algorithm. + /// Look at the util.partialMD5 method in the attached link. + /// Note: Only applies to epub files + /// + /// The hashing algorithm is relatively quick as it only hashes ~10,000 bytes for the biggest of files. + /// + /// The path to the file to hash + public static string HashContents(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath) || !Parser.IsEpub(filePath)) + { + return null; + } + + using var file = File.OpenRead(filePath); + + const int step = 1024; + const int size = 1024; + var md5 = MD5.Create(); + var buffer = new byte[size]; + + for (var i = -1; i < 10; i++) + { + file.Position = step << 2 * i; + var bytesRead = file.Read(buffer, 0, size); + if (bytesRead > 0) + { + md5.TransformBlock(buffer, 0, bytesRead, buffer, 0); + } + else + { + break; + } + } + + file.Close(); + md5.TransformFinalBlock([], 0, 0); + + return md5.Hash == null ? null : Convert.ToHexString(md5.Hash).ToUpper(); + } + + /// + /// Koreader can identify documents based on contents or title. + /// For now, we only support by contents. + /// + public static string HashTitle(string filePath) + { + var fileName = Path.GetFileName(filePath); + var fileNameBytes = Encoding.ASCII.GetBytes(fileName); + var bytes = MD5.HashData(fileNameBytes); + + return Convert.ToHexString(bytes); + } + + public static void UpdateProgressDto(ProgressDto progress, string koreaderPosition) + { + var path = koreaderPosition.Split('/'); + if (path.Length < 6) + { + return; + } + + var docNumber = path[2].Replace("DocFragment[", string.Empty).Replace("]", string.Empty); + progress.PageNum = int.Parse(docNumber) - 1; + var lastTag = path[5].ToUpper(); + + if (lastTag == "A") + { + progress.BookScrollId = null; + } + else + { + // The format that Kavita accepts as a progress string. It tells Kavita where Koreader last left off. + progress.BookScrollId = $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/{lastTag}"; + } + } + + + public static string GetKoreaderPosition(ProgressDto progressDto) + { + string lastTag; + var koreaderPageNumber = progressDto.PageNum + 1; + + if (string.IsNullOrEmpty(progressDto.BookScrollId)) + { + lastTag = "a"; + } + else + { + var tokens = progressDto.BookScrollId.Split('/'); + lastTag = tokens[^1].ToLower(); + } + + // The format that Koreader accepts as a progress string. It tells Koreader where Kavita last left off. + return $"/body/DocFragment[{koreaderPageNumber}]/body/div/{lastTag}"; + } +} diff --git a/API/Helpers/LibraryTypeHelper.cs b/API/Helpers/LibraryTypeHelper.cs deleted file mode 100644 index 423d93f0e..000000000 --- a/API/Helpers/LibraryTypeHelper.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using API.DTOs.Scrobbling; -using API.Entities.Enums; - -namespace API.Helpers; -#nullable enable - -public static class LibraryTypeHelper -{ - public static MediaFormat GetFormat(LibraryType libraryType) - { - // TODO: Refactor this to an extension on LibraryType - return libraryType switch - { - LibraryType.Manga => MediaFormat.Manga, - LibraryType.Comic => MediaFormat.Comic, - LibraryType.LightNovel => MediaFormat.LightNovel, - LibraryType.Book => MediaFormat.LightNovel, - LibraryType.Image => MediaFormat.Manga, - LibraryType.ComicVine => MediaFormat.Comic, - _ => throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null) - }; - } -} diff --git a/API/Helpers/OrderableHelper.cs b/API/Helpers/OrderableHelper.cs index 3313ca658..d4ff89573 100644 --- a/API/Helpers/OrderableHelper.cs +++ b/API/Helpers/OrderableHelper.cs @@ -9,6 +9,8 @@ public static class OrderableHelper { public static void ReorderItems(List items, int itemId, int toPosition) { + if (toPosition < 0) throw new ArgumentException("toPosition cannot be less than 0"); + var item = items.Find(r => r.Id == itemId); if (item != null) { @@ -24,6 +26,8 @@ public static class OrderableHelper public static void ReorderItems(List items, int itemId, int toPosition) { + if (toPosition < 0) throw new ArgumentException("toPosition cannot be less than 0"); + var item = items.Find(r => r.Id == itemId); if (item != null && toPosition < items.Count) { @@ -48,10 +52,15 @@ public static class OrderableHelper public static void ReorderItems(List items, int readingListItemId, int toPosition) { if (toPosition < 0) throw new ArgumentException("toPosition cannot be less than 0"); + var item = items.Find(r => r.Id == readingListItemId); if (item != null) { items.Remove(item); + + // Ensure toPosition is within the new list bounds + toPosition = Math.Min(toPosition, items.Count); + items.Insert(toPosition, item); } diff --git a/API/Helpers/PdfComicInfoExtractor.cs b/API/Helpers/PdfComicInfoExtractor.cs new file mode 100644 index 000000000..ce74ae97d --- /dev/null +++ b/API/Helpers/PdfComicInfoExtractor.cs @@ -0,0 +1,146 @@ +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ +using System; +using API.Data.Metadata; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using Nager.ArticleNumber; +using System.Collections.Generic; +using System.Globalization; + +namespace API.Helpers; +#nullable enable + +public interface IPdfComicInfoExtractor +{ + ComicInfo? GetComicInfo(string filePath); +} + +/// +/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure. +/// +public class PdfComicInfoExtractor : IPdfComicInfoExtractor +{ + private readonly ILogger _logger; + private readonly IMediaErrorService _mediaErrorService; + private readonly string[] _pdfDateFormats = [ // PDF Spec 7.9.4 + "D:yyyyMMddHHmmsszzz:", "D:yyyyMMddHHmmss+", "D:yyyyMMddHHmmss", + "D:yyyyMMddHHmmzzz:", "D:yyyyMMddHHmm+", "D:yyyyMMddHHmm", + "D:yyyyMMddHHzzz:", "D:yyyyMMddHH+", "D:yyyyMMddHH", + "D:yyyyMMdd", "D:yyyyMM", "D:yyyy" + ]; + + public PdfComicInfoExtractor(ILogger logger, IMediaErrorService mediaErrorService) + { + _logger = logger; + _mediaErrorService = mediaErrorService; + } + + private static float? GetFloatFromText(string? text) + { + if (string.IsNullOrEmpty(text)) return null; + + if (float.TryParse(text, CultureInfo.InvariantCulture, out var value)) return value; + + return null; + } + + private DateTime? GetDateTimeFromText(string? text) + { + if (string.IsNullOrEmpty(text)) return null; + + // Dates stored in the XMP metadata stream (PDF Spec 14.3.2) + // are stored in ISO 8601 format, which is handled by C# out of the box + if (DateTime.TryParse(text, CultureInfo.InvariantCulture, out var date)) return date; + + // Dates stored in the document information directory (PDF Spec 14.3.3) + // are stored in a proprietary format (PDF Spec 7.9.4) that needs to be + // massaged slightly to be expressible by a DateTime format. + if (text[0] != 'D') { + text = "D:" + text; + } + text = text.Replace("'", ":"); + text = text.Replace("Z", "+"); + + foreach(var format in _pdfDateFormats) + { + if (DateTime.TryParseExact(text, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var pdfDate)) return pdfDate; + } + + return null; + } + + private static string? MaybeGetMetadata(Dictionary metadata, string key) + { + return metadata.TryGetValue(key, out var value) ? value : null; + } + + private ComicInfo? GetComicInfoFromMetadata(Dictionary metadata, string filePath) + { + var info = new ComicInfo(); + + var publicationDate = GetDateTimeFromText(MaybeGetMetadata(metadata, "CreationDate")); + + if (publicationDate != null) + { + info.Year = publicationDate.Value.Year; + info.Month = publicationDate.Value.Month; + info.Day = publicationDate.Value.Day; + } + + info.Summary = MaybeGetMetadata(metadata, "Summary") ?? string.Empty; + info.Publisher = MaybeGetMetadata(metadata, "Publisher") ?? string.Empty; + info.Writer = MaybeGetMetadata(metadata, "Author") ?? string.Empty; + info.Title = MaybeGetMetadata(metadata, "Title") ?? string.Empty; + info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty; + info.Genre = MaybeGetMetadata(metadata, "Subject") ?? string.Empty; + info.LanguageISO = BookService.ValidateLanguage(MaybeGetMetadata(metadata, "Language")); + info.Isbn = MaybeGetMetadata(metadata, "ISBN") ?? string.Empty; + + if (info.Isbn != string.Empty && !ArticleNumberHelper.IsValidIsbn10(info.Isbn) && !ArticleNumberHelper.IsValidIsbn13(info.Isbn)) + { + _logger.LogDebug("[BookService] {File} has an invalid ISBN number", filePath); + info.Isbn = string.Empty; + } + + info.UserRating = GetFloatFromText(MaybeGetMetadata(metadata, "UserRating")) ?? 0.0f; + info.Series = MaybeGetMetadata(metadata, "Series") ?? info.Title; + info.SeriesSort = info.Series; + info.Volume = MaybeGetMetadata(metadata, "Volume") ?? string.Empty; + + // If this is a single book and not a collection, set publication status to Completed + if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) + { + info.Count = 1; + } + + ComicInfo.CleanComicInfo(info); + + return info; + } + + public ComicInfo? GetComicInfo(string filePath) + { + try + { + var extractor = new PdfMetadataExtractor(_logger, filePath); + + return GetComicInfoFromMetadata(extractor.GetMetadata(), filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing PDF metadata for {File}", filePath); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception parsing PDF metadata", ex); + } + + return null; + } +} diff --git a/API/Helpers/PdfMetadataExtractor.cs b/API/Helpers/PdfMetadataExtractor.cs new file mode 100644 index 000000000..44327672b --- /dev/null +++ b/API/Helpers/PdfMetadataExtractor.cs @@ -0,0 +1,1637 @@ +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ + +using System; +using System.Collections.Generic; +using System.IO.Compression; +using System.Text; +using System.Xml; +using System.IO; +using Microsoft.Extensions.Logging; +using API.Services; + +namespace API.Helpers; +#nullable enable + +/// +/// Parse PDF file and try to extract as much metadata as possible. +/// Supports both text based XRef tables and compressed XRef streams (Deflate only). +/// Supports both UTF-16 and PDFDocEncoding for strings. +/// Lacks support for many PDF configurations that are theoretically possible, but should handle most common cases. +/// +public class PdfMetadataExtractorException : Exception +{ + public PdfMetadataExtractorException() + { + } + + public PdfMetadataExtractorException(string message) + : base(message) + { + } + + public PdfMetadataExtractorException(string message, Exception inner) + : base(message, inner) + { + } +} + +public interface IPdfMetadataExtractor +{ + Dictionary GetMetadata(); +} + +class PdfStringBuilder +{ + private readonly StringBuilder _builder = new(); + private bool _secondByte = false; + private byte _prevByte = 0; + private bool _isUnicode = false; + + // PDFDocEncoding defined in PDF Spec D.1 + + private readonly char[] _pdfDocMappingLow = + [ + '\u02D8', '\u02C7', '\u02C6', '\u02D9', '\u02DD', '\u02DB', '\u02DA', '\u02DC' + ]; + + private readonly char[] _pdfDocMappingHigh = + [ + '\u2022', '\u2020', '\u2021', '\u2026', '\u2014', '\u2013', '\u0192', '\u2044', + '\u2039', '\u203A', '\u2212', '\u2030', '\u201E', '\u201C', '\u201D', '\u2018', + '\u2019', '\u201A', '\u2122', '\uFB01', '\uFB02', '\u0141', '\u0152', '\u0160', + '\u0178', '\u017D', '\u0131', '\u0142', '\u0153', '\u0161', '\u017E', ' ', + '\u20AC' + ]; + + private void AppendPdfDocByte(byte b) + { + if (b >= 0x18 && b < 0x20) + { + _builder.Append(_pdfDocMappingLow[b - 0x18]); + } + else if (b >= 0x80 && b < 0xA1) + { + _builder.Append(_pdfDocMappingHigh[b - 0x80]); + } + else + { + _builder.Append((char)b); + } + } + + public void Append(char c) + { + _builder.Append(c); + } + + public void AppendByte(byte b) + { + // PDF Spec 7.9.2.1: Strings are either UTF-16BE or PDFDocEncoded + if (_builder.Length == 0 && !_isUnicode) + { + // Unicode strings are prefixed by a big endian BOM \uFEFF + if (_secondByte) + { + if (b == 0xFF) + { + _isUnicode = true; + _secondByte = false; + } + else + { + AppendPdfDocByte(_prevByte); + AppendPdfDocByte(b); + } + } + else if (!_secondByte && b == 0xFE) + { + _secondByte = true; + _prevByte = b; + } + else + { + AppendPdfDocByte(b); + } + } + else if (_isUnicode) + { + if (_secondByte) + { + _builder.Append((char)(((char)_prevByte) << 8 | (char)b)); + _secondByte = false; + } + else + { + _prevByte = b; + _secondByte = true; + } + } + else + { + AppendPdfDocByte(b); + } + } + + override public string ToString() + { + if (_builder.Length == 0 && _secondByte) + { + AppendPdfDocByte(_prevByte); + } + + return _builder.ToString(); + } +} + +internal class PdfLexer(Stream stream) +{ + private const int BufferSize = 1024; + private readonly byte[] _buffer = new byte[BufferSize]; + private int _pos = 0; + private int _valid = 0; + + public enum TokenType + { + None, + Bool, + Int, + Double, + Name, + String, + ArrayStart, + ArrayEnd, + DictionaryStart, + DictionaryEnd, + StreamStart, + StreamEnd, + ObjectStart, + ObjectEnd, + ObjectRef, + Keyword, + Newline, + } + + public struct Token(TokenType type, object value) + { + public TokenType Type = type; + public object Value = value; + } + + public Token NextToken(bool reportNewlines = false) + { + while (true) + { + switch ((char)NextByte()) + { + case '\n' when reportNewlines: + return new Token(TokenType.Newline, true); + + case '\r' when reportNewlines: + if (NextByte() != '\n') + { + PutBack(); + } + return new Token(TokenType.Newline, true); + + case ' ': + case '\x00': + case '\t': + case '\n': + case '\f': + case '\r': + continue; // Skip whitespace + + case '%': + SkipComment(); + continue; + + case '+': + case '-': + case '.': + case >= '0' and <= '9': + return ScanNumber(); + + case '/': + return ScanName(); + + case '(': + return ScanString(); + + case '[': + return new Token(TokenType.ArrayStart, true); + + case ']': + return new Token(TokenType.ArrayEnd, true); + + case '<': + if (NextByte() == '<') + { + return new Token(TokenType.DictionaryStart, true); + } + else + { + PutBack(); + return ScanHexString(); + } + case '>': + ExpectByte((byte)'>'); + + return new Token(TokenType.DictionaryEnd, true); + + case >= 'a' and <= 'z': + case >= 'A' and <= 'Z': + return ScanKeyword(); + + default: + throw new PdfMetadataExtractorException("Unexpected byte, got {LastByte()}"); + } + } + } + + public void ResetBuffer() + { + _pos = 0; + _valid = 0; + } + + public bool TestByte(byte expected) + { + var result = NextByte() == expected; + + PutBack(); + + return result; + } + + public void ExpectNewline() + { + while (true) + { + var b = NextByte(); + switch ((char)b) + { + case ' ': + case '\t': + case '\f': + continue; // Skip whitespace + + case '\n': + return; + + case '\r': + if (NextByte() != '\n') + { + PutBack(); + } + + return; + + default: + throw new PdfMetadataExtractorException("Unexpected character, expected newline, got {b}"); + } + } + } + + public long GetXRefStart() + { + // Look for the startxref element as per PDF Spec 7.5.5 + while (true) + { + var b = NextByte(); + + switch ((char)b) + { + case '\r': + b = NextByte(); + + if (b != '\n') + { + PutBack(); + } + + goto case '\n'; + + case '\n': + // Handle consecutive newlines + while (true) + { + b = NextByte(); + + if (b == '\r') + { + goto case '\r'; + } + else if (b == '\n') + { + goto case '\n'; + } + else if (b == ' ' || b == '\t' || b == '\f') + { + continue; + } + else + { + PutBack(); + + break; + } + } + + var token = NextToken(true); + + if (token.Type == TokenType.Keyword && (string)token.Value == "startxref") + { + token = NextToken(); + + if (token.Type == TokenType.Int) + { + return (long)token.Value; + } + else + { + throw new PdfMetadataExtractorException("Expected integer after startxref keyword"); + } + } + + continue; + + default: + continue; + } + } + } + + public bool NextXRefEntry(ref long obj, ref int generation) + { + // Cross-reference table entry as per PDF Spec 7.5.4 + + WantLookahead(20); + + if (_valid - _pos < 20) + { + throw new PdfMetadataExtractorException("End of stream"); + } + + var inUse = true; + + if (obj == 0) + { + obj = Convert.ToInt64(Encoding.ASCII.GetString(_buffer, _pos, 10)); + generation = Convert.ToInt32(Encoding.ASCII.GetString(_buffer, _pos + 11, 5)); + inUse = _buffer[_pos + 17] == 'n'; + } + + _pos += 20; + + return inUse; + } + + public Stream StreamObject(int length, bool deflate) + { + // Read a stream object as per PDF Spec 7.3.8 + // At the moment, we only accept uncompressed streams or the FlateDecode (PDF Spec 7.4.1) filter + // with no parameters. These cover the vast majority of streams we're interested in. + + var rawData = new MemoryStream(); + + ExpectNewline(); + + if (_pos < _valid) + { + var buffered = Math.Min(_valid - _pos, length); + rawData.Write(_buffer, _pos, buffered); + length -= buffered; + _pos += buffered; + } + + while (length > 0) + { + var buffered = Math.Min(length, BufferSize); + stream.ReadExactly(_buffer, 0, buffered); + rawData.Write(_buffer, 0, buffered); + _pos = 0; + _valid = 0; + length -= buffered; + } + + rawData.Seek(0, SeekOrigin.Begin); + + if (deflate) + { + return new ZLibStream(rawData, CompressionMode.Decompress, false); + } + else + { + return rawData; + } + } + + private byte NextByte() + { + if (_pos >= _valid) + { + _pos = 0; + _valid = stream.Read(_buffer, 0, BufferSize); + + if (_valid <= 0) + { + throw new PdfMetadataExtractorException("End of stream"); + } + } + + return _buffer[_pos++]; + } + + private byte LastByte() + { + return _buffer[_pos - 1]; + } + + private void PutBack() + { + --_pos; + } + + private void ExpectByte(byte expected) + { + if (NextByte() != expected) + { + throw new PdfMetadataExtractorException($"Unexpected character, expected {expected}"); + } + } + + private void WantLookahead(int length) + { + if (_pos + length > _valid) + { + Buffer.BlockCopy(_buffer, _pos, _buffer, 0, _valid - _pos); + _valid -= _pos; + _pos = 0; + _valid += stream.Read(_buffer, _valid, BufferSize - _valid); + } + } + + private void SkipComment() + { + while (true) + { + var b = NextByte(); + + if (b == '\n') + { + break; + } + else if (b == '\r') + { + if (NextByte() != '\n') + { + PutBack(); + } + + break; + } + } + } + + private Token ScanNumber() + { + StringBuilder sb = new(); + var hasDot = LastByte() == '.'; + var followedBySpace = false; + + sb.Append((char)LastByte()); + + while (true) + { + var b = NextByte(); + + if (b == '.' || b >= '0' && b <= '9') + { + sb.Append((char)b); + + if (b == '.') + { + hasDot = true; + } + } + else + { + followedBySpace = (b == ' ' || b == '\t'); + PutBack(); + + break; + } + } + + if (hasDot) + { + return new Token(TokenType.Double, double.Parse(sb.ToString())); + } + + if (followedBySpace) + { + // Look ahead to see if it's an object reference (PDF Spec 7.3.10) + WantLookahead(32); + + var savedPos = _pos; + var b = NextByte(); + + while (b == ' ' || b == '\t') + { + b = NextByte(); + } + + // Generation number (ignored) + while (b >= '0' && b <= '9') + { + b = NextByte(); + } + + while (b == ' ' || b == '\t') + { + b = NextByte(); + } + + if (b == 'R') + { + return new Token(TokenType.ObjectRef, long.Parse(sb.ToString())); + } + else if (b == 'o' && NextByte() == 'b' && NextByte() == 'j') + { + return new Token(TokenType.ObjectStart, long.Parse(sb.ToString())); + } + else + { + _pos = savedPos; + } + } + + return new Token(TokenType.Int, long.Parse(sb.ToString())); + } + + private static int HexDigit(byte b) + { + return (char) b switch + { + >= '0' and <= '9' => b - (byte) '0', + >= 'a' and <= 'f' => b - (byte) 'a' + 10, + >= 'A' and <= 'F' => b - (byte) 'A' + 10, + _ => throw new PdfMetadataExtractorException("Invalid hex digit, got {b}") + }; + } + + private Token ScanName() + { + // PDF Spec 7.3.5 + + var sb = new StringBuilder(); + while (true) + { + var b = NextByte(); + switch ((char)b) + { + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + case '<': + case '>': + case '/': + case '%': + PutBack(); + + goto case ' '; + + case ' ': + case '\t': + case '\n': + case '\f': + case '\r': + return new Token(TokenType.Name, sb.ToString()); + + case '#': + var b1 = NextByte(); + var b2 = NextByte(); + b = (byte)((HexDigit(b1) << 4) | HexDigit(b2)); + + goto default; + + default: + sb.Append((char)b); + break; + } + } + } + + private Token ScanString() + { + // PDF Spec 7.3.4.2 + + PdfStringBuilder sb = new(); + var parenLevel = 1; + + while (true) + { + var b = NextByte(); + + switch ((char)b) + { + case '(': + parenLevel++; + + goto default; + + case ')': + if (--parenLevel == 0) + { + return new Token(TokenType.String, sb.ToString()); + } + + goto default; + + case '\\': + b = NextByte(); + + switch ((char)b) + { + case 'b': + sb.Append('\b'); + + break; + + case 'f': + sb.Append('\f'); + + break; + + case 'n': + sb.Append('\n'); + + break; + + case 'r': + sb.Append('\r'); + + break; + + case 't': + sb.Append('\t'); + + break; + + case >= '0' and <= '7': + var b1 = b; + var b2 = NextByte(); + var b3 = NextByte(); + + if (b2 < '0' || b2 > '7' || b3 < '0' || b3 > '7') + { + throw new PdfMetadataExtractorException("Invalid octal escape, got {b1}{b2}{b3}"); + } + + sb.AppendByte((byte)((b1 - '0') << 6 | (b2 - '0') << 3 | (b3 - '0'))); + + break; + } + break; + + default: + sb.AppendByte(b); + break; + } + } + } + + private Token ScanHexString() + { + // PDF Spec 7.3.4.3 + + PdfStringBuilder sb = new(); + + while (true) + { + var b = NextByte(); + + switch ((char)b) + { + case (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'): + var b1 = NextByte(); + if (b1 == '>') + { + PutBack(); + b1 = (byte)'0'; + } + sb.AppendByte((byte)(HexDigit(b) << 4 | HexDigit(b1))); + + break; + + case '>': + return new Token(TokenType.String, sb.ToString()); + + default: + throw new PdfMetadataExtractorException("Invalid hex string, got {b}"); + } + } + } + + private Token ScanKeyword() + { + StringBuilder sb = new(); + + sb.Append((char)LastByte()); + + while (true) + { + var b = NextByte(); + if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')) + { + sb.Append((char)b); + } + else + { + PutBack(); + + break; + } + } + + switch (sb.ToString()) + { + case "true": + return new Token(TokenType.Bool, true); + + case "false": + return new Token(TokenType.Bool, false); + + case "stream": + return new Token(TokenType.StreamStart, true); + + case "endstream": + return new Token(TokenType.StreamEnd, true); + + case "endobj": + return new Token(TokenType.ObjectEnd, true); + + default: + return new Token(TokenType.Keyword, sb.ToString()); + } + } +} + +internal class PdfMetadataExtractor : IPdfMetadataExtractor +{ + private readonly ILogger _logger; + private readonly PdfLexer _lexer; + private readonly FileStream _stream; + private long[] _objectOffsets = new long[0]; + private readonly Dictionary _metadata = []; + private readonly Stack _metadataRef = new(); + + private struct MetadataRef(long root, long info) + { + public long Root = root; + public long Info = info; + } + + private struct XRefSection(long first, long count) + { + public readonly long First = first; + public readonly long Count = count; + } + + public PdfMetadataExtractor(ILogger logger, string filename) + { + _logger = logger; + _stream = File.OpenRead(filename); + _lexer = new PdfLexer(_stream); + + ReadObjectOffsets(); + ReadMetadata(filename); + } + + public Dictionary GetMetadata() + { + return _metadata; + } + + private void LogMetadata(string filename) + { + _logger.LogTrace("Metadata for {Path}:", filename); + + foreach (var entry in _metadata) + { + _logger.LogTrace(" {Key:0,-5} : {Value:1}", entry.Key, entry.Value); + } + } + + private void ReadObjectOffsets() + { + // Look for file trailer (PDF Spec 7.5.5) + // Spec says trailer must be strictly at end of file. + // Adobe software accepts trailer within last 1K of EOF, + // but in practice, virtually all PDFs have trailer at end. + + _stream.Seek(-32, SeekOrigin.End); + + var xrefOffset = _lexer.GetXRefStart(); + + ReadXRefAndTrailer(xrefOffset); + } + + private void ReadXRefAndTrailer(long xrefOffset) + { + _stream.Seek(xrefOffset, SeekOrigin.Begin); + _lexer.ResetBuffer(); + + if (!_lexer.TestByte((byte)'x')) + { + // Cross-reference stream (PDF Spec 7.5.8) + + ReadXRefStream(); + + return; + } + + // Cross-reference table (PDF Spec 7.5.4) + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Keyword || (string)token.Value != "xref") + { + throw new PdfMetadataExtractorException("Expected xref keyword"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.Int) + { + var startObj = (long)token.Value; + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected number of objects in xref subsection"); + } + + var numObj = (long)token.Value; + + if (_objectOffsets.Length < startObj + numObj) + { + Array.Resize(ref _objectOffsets, (int)(startObj + numObj)); + } + + _lexer.ExpectNewline(); + + var generation = 0; + + for (var obj = startObj; obj < startObj + numObj; ++obj) + { + var inUse = _lexer.NextXRefEntry(ref _objectOffsets[obj], ref generation); + + if (!inUse) + { + _objectOffsets[obj] = 0; + } + } + } + else if (token.Type == PdfLexer.TokenType.Keyword && (string)token.Value == "trailer") + { + break; + } + else + { + throw new PdfMetadataExtractorException("Unexpected token in xref"); + } + } + + ReadTrailerDictionary(); + } + + private void ReadXRefStream() + { + // Cross-reference stream (PDF Spec 7.5.8) + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected obj keyword"); + } + + long length = -1; + long size = -1; + var deflate = false; + long prev = -1; + long typeWidth = -1; + long offsetWidth = -1; + long generationWidth = -1; + Queue sections = new(); + var meta = new MetadataRef(-1, -1); + + // Cross-reference stream dictionary (PDF Spec 7.5.8.2) + + ParseDictionary(delegate(string key, PdfLexer.Token value) { + switch (key) + { + case "Type": + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XRef") + { + throw new PdfMetadataExtractorException("Expected /Type to be /XRef"); + } + + return true; + + case "Length": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Length"); + } + + length = (long)value.Value; + + return true; + + case "Size": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Size"); + } + + size = (long)value.Value; + + return true; + + case "Prev": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /Prev"); + } + + prev = (long)value.Value; + + return true; + + case "Index": + if (value.Type != PdfLexer.TokenType.ArrayStart) + { + throw new PdfMetadataExtractorException("Expected array after /Index"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.ArrayEnd) + { + break; + } + else if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer in /Index array"); + } + + var first = (long)token.Value; + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer pair in /Index array"); + } + + var count = (long)token.Value; + sections.Enqueue(new XRefSection(first, count)); + } + + return true; + + case "W": + if (value.Type != PdfLexer.TokenType.ArrayStart) + { + throw new PdfMetadataExtractorException("Expected array after /W"); + } + + var widths = new long[3]; + + for (var i = 0; i < 3; ++i) + { + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer in /W array"); + } + + widths[i] = (long)token.Value; + } + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ArrayEnd) + { + throw new PdfMetadataExtractorException("Unclosed array after /W"); + } + + typeWidth = widths[0]; + offsetWidth = widths[1]; + generationWidth = widths[2]; + + return true; + + case "Filter": + if (value.Type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name after /Filter"); + } + + if ((string)value.Value != "FlateDecode") + { + throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); + } + + deflate = true; + + return true; + + case "Root": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Root"); + } + + meta.Root = (long)value.Value; + + return true; + + case "Info": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Info"); + } + + meta.Info = (long)value.Value; + + return true; + + default: + return false; + } + }); + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.StreamStart) + { + throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); + } + + var stream = _lexer.StreamObject((int)length, deflate); + + if (sections.Count == 0) + { + sections.Enqueue(new XRefSection(0, size)); + } + + while (sections.Count > 0) + { + var section = sections.Dequeue(); + + if (_objectOffsets.Length < size) + { + Array.Resize(ref _objectOffsets, (int)size); + } + + for (var i = section.First; i < section.First + section.Count; ++i) + { + long type = 0; + long offset = 0; + long generation = 0; + + if (typeWidth == 0) + { + type = 1; + } + + for (var j = 0; j < typeWidth; ++j) + { + type = (type << 8) | (ushort)stream.ReadByte(); + } + + for (var j = 0; j < offsetWidth; ++j) + { + offset = (offset << 8) | (ushort)stream.ReadByte(); + } + + for (var j = 0; j < generationWidth; ++j) + { + generation = (generation << 8) | (ushort)stream.ReadByte(); + } + + if (type == 1 && _objectOffsets[i] == 0) + { + _objectOffsets[i] = offset; + } + } + } + + if (prev > -1) + { + ReadXRefAndTrailer(prev); + } + + PushMetadataRef(meta); + } + + private void PushMetadataRef(MetadataRef meta) + { + if (_metadataRef.Count > 0) + { + if (meta.Root == _metadataRef.Peek().Root) + { + meta.Root = -1; + } + + if (meta.Info == _metadataRef.Peek().Info) + { + meta.Info = -1; + } + } + + if (meta.Root != -1 || meta.Info != -1) + { + _metadataRef.Push(meta); + } + } + + private void ReadTrailerDictionary() + { + // Read trailer directory (PDF Spec 7.5.5) + + long prev = -1; + long xrefStm = -1; + + MetadataRef meta = new(-1, -1); + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) + { + case "Root": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Root"); + } + + meta.Root = (long)value.Value; + + return true; + case "Prev": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /Prev"); + } + + prev = (long)value.Value; + + return true; + case "Info": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Info"); + } + + meta.Info = (long)value.Value; + + return true; + case "XRefStm": + // Prefer encoded xref stream over xref table + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /XRefStm"); + } + + xrefStm = (long)value.Value; + + return true; + + case "Encrypt": + throw new PdfMetadataExtractorException("Encryption not supported"); + + default: + return false; + } + }); + + PushMetadataRef(meta); + + if (xrefStm != -1) + { + ReadXRefAndTrailer(xrefStm); + } + + if (prev != -1) + { + ReadXRefAndTrailer(prev); + } + } + + private void ReadMetadata(string filename) + { + // We read potential metadata sources in backwards historical order, so + // we can overwrite to our heart's content + + while (_metadataRef.Count > 0) + { + var meta = _metadataRef.Pop(); + + //_logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.root, meta.info); + + ReadMetadataFromInfo(meta.Info); + ReadMetadataFromXml(MetadataObjInObjectCatalog(meta.Root)); + } + } + + private void ReadMetadataFromInfo(long infoObj) + { + // Document information dictionary (PDF Spec 14.3.3) + // We treat this as less authoritative than the Metadata stream. + + if (infoObj < 1 || infoObj >= _objectOffsets.Length || _objectOffsets[infoObj] == 0) + { + return; + } + + _stream.Seek(_objectOffsets[infoObj], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + Dictionary indirectObjects = []; + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) + { + case "Title": + case "Author": + case "Subject": + case "Keywords": + case "Creator": + case "Producer": + case "CreationDate": + case "ModDate": + if (value.Type == PdfLexer.TokenType.ObjectRef) { + indirectObjects[key] = (long)value.Value; + } + else if (value.Type != PdfLexer.TokenType.String) + { + throw new PdfMetadataExtractorException("Expected string value"); + } + else + { + _metadata[key] = (string)value.Value; + } + + return true; + + default: + return false; + } + }); + + // Resolve indirectly referenced values + foreach(var key in indirectObjects.Keys) { + _stream.Seek(_objectOffsets[indirectObjects[key]], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) { + throw new PdfMetadataExtractorException("Expected object here"); + } + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.String) { + throw new PdfMetadataExtractorException("Expected string"); + } + + _metadata[key] = (string) token.Value; + } + } + + private long MetadataObjInObjectCatalog(long rootObj) + { + // Look for /Metadata entry in document catalog (PDF Spec 7.7.2) + + if (rootObj < 1 || rootObj >= _objectOffsets.Length || _objectOffsets[rootObj] == 0) + { + return -1; + } + + _stream.Seek(_objectOffsets[rootObj], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + long meta = -1; + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) { + case "Metadata": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object number after /Metadata"); + } + + meta = (long)value.Value; + + return true; + + default: + return false; + } + }); + + return meta; + } + + // Obtain metadata from XMP stream object + // See XMP specification: https://developer.adobe.com/xmp/docs/XMPSpecifications/ + // and Dublin Core: https://www.dublincore.org/specifications/dublin-core/ + + private static string? GetTextFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + { + return (doc.DocumentElement?.SelectSingleNode(path + "//rdf:li", ns) + ?? doc.DocumentElement?.SelectSingleNode(path, ns))?.InnerText; + } + + private static string? GetListFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + { + var nodes = doc.DocumentElement?.SelectNodes(path + "//rdf:li", ns); + + if (nodes == null) return null; + + var list = new StringBuilder(); + + foreach (XmlNode n in nodes) + { + if (list.Length > 0) + { + list.Append(','); + } + + list.Append(n.InnerText); + } + + return list.Length > 0 ? list.ToString() : null; + } + + private void SetMetadata(string key, string? value) + { + if (value == null) return; + + _metadata[key] = value; + } + + private void ReadMetadataFromXml(long meta) + { + if (meta < 1 || meta >= _objectOffsets.Length || _objectOffsets[meta] == 0) return; + + _stream.Seek(_objectOffsets[meta], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + long length = -1; + var deflate = false; + + // Metadata stream dictionary (PDF Spec 14.3.2) + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) { + case "Type": + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "Metadata") + { + throw new PdfMetadataExtractorException("Expected /Type to be /Metadata"); + } + + return true; + + case "Subtype": + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XML") + { + throw new PdfMetadataExtractorException("Expected /Subtype to be /XML"); + } + + return true; + + case "Length": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Length"); + } + + length = (long)value.Value; + + return true; + + case "Filter": + if (value.Type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name after /Filter"); + } + + if ((string)value.Value != "FlateDecode") + { + throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); + } + + deflate = true; + + return true; + + default: + return false; + } + }); + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.StreamStart) + { + throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); + } + + var xmlStream = _lexer.StreamObject((int)length, deflate); + + // Skip XMP header + while (true) { + var b = xmlStream.ReadByte(); + + if (b < 0) { + throw new PdfMetadataExtractorException("Reached EOF in XMP header"); + } + + if (b == '?') { + while (b == '?') { + b = xmlStream.ReadByte(); + } + + if (b == '>') { + break; + } + } + } + + var metaDoc = new XmlDocument(); + metaDoc.Load(xmlStream); + + var ns = new XmlNamespaceManager(metaDoc.NameTable); + ns.AddNamespace("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"); + ns.AddNamespace("dc", "http://purl.org/dc/elements/1.1/"); + ns.AddNamespace("calibreSI", "http://calibre-ebook.com/xmp-namespace-series-index"); + ns.AddNamespace("calibre", "http://calibre-ebook.com/xmp-namespace"); + ns.AddNamespace("pdfx", "http://ns.adobe.com/pdfx/1.3/"); + ns.AddNamespace("prism", "http://prismstandard.org/namespaces/basic/2.0/"); + ns.AddNamespace("xmp", "http://ns.adobe.com/xap/1.0/"); + + SetMetadata("CreationDate", + GetTextFromXmlNode(metaDoc, ns, "//dc:date") + ?? GetTextFromXmlNode(metaDoc, ns, "//xmp:CreateDate")); + SetMetadata("Summary", GetTextFromXmlNode(metaDoc, ns, "//dc:description")); + SetMetadata("Publisher", GetTextFromXmlNode(metaDoc, ns, "//dc:publisher")); + SetMetadata("Author", GetListFromXmlNode(metaDoc, ns, "//dc:creator")); + SetMetadata("Title", GetTextFromXmlNode(metaDoc, ns, "//dc:title")); + SetMetadata("Subject", GetListFromXmlNode(metaDoc, ns, "//dc:subject")); + SetMetadata("Language", GetTextFromXmlNode(metaDoc, ns, "//dc:language")); + SetMetadata("ISBN", GetTextFromXmlNode(metaDoc, ns, "//pdfx:isbn") ?? GetTextFromXmlNode(metaDoc, ns, "//prism:isbn")); + SetMetadata("UserRating", GetTextFromXmlNode(metaDoc, ns, "//calibre:rating")); + SetMetadata("TitleSort", GetTextFromXmlNode(metaDoc, ns, "//calibre:title_sort")); + SetMetadata("Series", GetTextFromXmlNode(metaDoc, ns, "//calibre:series/rdf:value")); + SetMetadata("Volume", GetTextFromXmlNode(metaDoc, ns, "//calibreSI:series_index")); + } + + private delegate bool DictionaryHandler(string key, PdfLexer.Token value); + + private void ParseDictionary(DictionaryHandler handler) + { + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.DictionaryStart) + { + throw new PdfMetadataExtractorException("Expected dictionary"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.DictionaryEnd) + { + return; + } + + if (token.Type == PdfLexer.TokenType.Name) + { + var value = _lexer.NextToken(); + + if (!handler((string)token.Value, value)) { + SkipValue(value); + } + } + else + { + throw new PdfMetadataExtractorException("Improper token in dictionary"); + } + } + } + + private void SkipValue(PdfLexer.Token? existingToken = null) + { + var token = existingToken ?? _lexer.NextToken(); + + switch (token.Type) + { + case PdfLexer.TokenType.Bool: + case PdfLexer.TokenType.Int: + case PdfLexer.TokenType.Double: + case PdfLexer.TokenType.Name: + case PdfLexer.TokenType.String: + case PdfLexer.TokenType.ObjectRef: + break; + case PdfLexer.TokenType.ArrayStart: + { + SkipArray(); + break; + } + case PdfLexer.TokenType.DictionaryStart: + { + SkipDictionary(); + break; + } + default: + throw new PdfMetadataExtractorException("Unexpected token in SkipValue"); + } + } + + private void SkipArray() + { + while (true) + { + var token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.ArrayEnd) + { + break; + } + + SkipValue(token); + } + } + + private void SkipDictionary() + { + while (true) + { + var token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.DictionaryEnd) + { + break; + } + if (token.Type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name in dictionary"); + } + + SkipValue(); + } + } +} diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 193513453..b71ff2c1a 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -7,6 +6,7 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers.Builders; @@ -17,6 +17,20 @@ namespace API.Helpers; public static class PersonHelper { + public static Dictionary ConstructNameAndAliasDictionary(IList people) + { + var dict = new Dictionary(); + foreach (var person in people) + { + dict.TryAdd(person.NormalizedName, person); + foreach (var alias in person.Aliases) + { + dict.TryAdd(alias.NormalizedAlias, person); + } + } + return dict; + } + public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection metadataPeople, IEnumerable chapterPeople, PersonRole role, IUnitOfWork unitOfWork) { @@ -38,7 +52,9 @@ public static class PersonHelper // Identify people to remove from metadataPeople var peopleToRemove = existingMetadataPeople - .Where(person => !peopleToAddSet.Contains(person.Person.NormalizedName)) + .Where(person => + !peopleToAddSet.Contains(person.Person.NormalizedName) && + !person.Person.Aliases.Any(pa => peopleToAddSet.Contains(pa.NormalizedAlias))) .ToList(); // Remove identified people from metadataPeople @@ -53,11 +69,7 @@ public static class PersonHelper .GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList()); // Prepare a dictionary for quick lookup of existing people by normalized name - var existingPeopleDict = new Dictionary(); - foreach (var person in existingPeopleInDb) - { - existingPeopleDict.TryAdd(person.NormalizedName, person); - } + var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeopleInDb); // Track the people to attach (newly created people) var peopleToAttach = new List(); @@ -80,7 +92,6 @@ public static class PersonHelper // If not, create a new Person entity using the real name dbPerson = new PersonBuilder(personName).Build(); peopleToAttach.Add(dbPerson); // Add new person to the list to be attached - modification = true; } // Add the person to the SeriesMetadataPeople collection @@ -130,15 +141,12 @@ public static class PersonHelper var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople); // Prepare a dictionary for quick lookup by normalized name - var existingPeopleDict = new Dictionary(); - foreach (var person in existingPeople) - { - existingPeopleDict.TryAdd(person.NormalizedName, person); - } + var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeople); // Identify people to remove (those present in ChapterPeople but not in the new list) - foreach (var existingChapterPerson in existingChapterPeople - .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName))) + var toRemove = existingChapterPeople + .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName)); + foreach (var existingChapterPerson in toRemove) { chapter.People.Remove(existingChapterPerson); unitOfWork.PersonRepository.Remove(existingChapterPerson); diff --git a/API/Services/ReviewService.cs b/API/Helpers/ReviewHelper.cs similarity index 84% rename from API/Services/ReviewService.cs rename to API/Helpers/ReviewHelper.cs index c2c876b4b..03c50a4cf 100644 --- a/API/Services/ReviewService.cs +++ b/API/Helpers/ReviewHelper.cs @@ -5,10 +5,9 @@ using System.Text.RegularExpressions; using API.DTOs.SeriesDetail; using HtmlAgilityPack; +namespace API.Helpers; -namespace API.Services; - -public static class ReviewService +public static class ReviewHelper { private const int BodyTextLimit = 175; public static IEnumerable SelectSpectrumOfReviews(IList reviews) @@ -60,6 +59,9 @@ public static class ReviewService .Where(s => !s.Equals("\n"))); // Clean any leftover markdown out + plainText = Regex.Replace(plainText, @"\*\*(.*?)\*\*", "$1"); // Bold with ** + plainText = Regex.Replace(plainText, @"_(.*?)_", "$1"); // Italic with _ + plainText = Regex.Replace(plainText, @"\[(.*?)\]\((.*?)\)", "$1"); // Links [text](url) plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty); plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty); plainText = Regex.Replace(plainText, @"~~~(.*?)~~~", "$1"); @@ -68,6 +70,7 @@ public static class ReviewService plainText = Regex.Replace(plainText, @"__(.*?)__", "$1"); plainText = Regex.Replace(plainText, @"#\s(.*?)", "$1"); + // Just strip symbols plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty); plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty); @@ -76,8 +79,8 @@ public static class ReviewService plainText = Regex.Replace(plainText, @"~~", string.Empty); plainText = Regex.Replace(plainText, @"__", string.Empty); - // Take the first 100 characters - plainText = plainText.Length > 100 ? plainText.Substring(0, BodyTextLimit) : plainText; + // Take the first BodyTextLimit characters + plainText = plainText.Length > BodyTextLimit ? plainText.Substring(0, BodyTextLimit) : plainText; return plainText + "…"; } diff --git a/API/Helpers/StringHelper.cs b/API/Helpers/StringHelper.cs new file mode 100644 index 000000000..0a20910c5 --- /dev/null +++ b/API/Helpers/StringHelper.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; + +namespace API.Helpers; +#nullable enable + +public static partial class StringHelper +{ + #region Regex Source Generators + [GeneratedRegex(@"\s?\(Source:\s*[^)]+\)")] + private static partial Regex SourceRegex(); + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex BrStandardizeRegex(); + [GeneratedRegex(@"(?:
\s*)+", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex BrMultipleRegex(); + [GeneratedRegex(@"\s+")] + private static partial Regex WhiteSpaceRegex(); + [GeneratedRegex("&#64;")] + private static partial Regex HtmlEncodedAtSymbolRegex(); + #endregion + + /// + /// Used to squash duplicate break and new lines with a single new line. + /// + /// Test br br Test -> Test br Test + /// + /// + public static string? SquashBreaklines(string? summary) + { + if (string.IsNullOrWhiteSpace(summary)) + { + return null; + } + + // First standardize all br tags to
format + summary = BrStandardizeRegex().Replace(summary, "
"); + + // Replace multiple consecutive br tags with a single br tag + summary = BrMultipleRegex().Replace(summary, "
"); + + // Normalize remaining whitespace (replace multiple spaces with a single space) + summary = WhiteSpaceRegex().Replace(summary, " ").Trim(); + + return summary.Trim(); + } + + /// + /// Removes the (Source: MangaDex) type of tags at the end of descriptions from AL + /// + /// + /// + public static string? RemoveSourceInDescription(string? description) + { + if (string.IsNullOrEmpty(description)) return description; + + return SourceRegex().Replace(description, string.Empty).Trim(); + } + + /// + /// Replaces some HTML encoded characters in urls with the proper symbol. This is common in People Description's + /// + /// + /// + public static string? CorrectUrls(string? description) + { + if (string.IsNullOrEmpty(description)) return description; + + return HtmlEncodedAtSymbolRegex().Replace(description, "@"); + } +} diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index cceecc826..c00d6ee8f 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -20,7 +20,13 @@ public static class TagHelper public static async Task UpdateChapterTags(Chapter chapter, IEnumerable tagNames, IUnitOfWork unitOfWork) { // Normalize tag names once and store them in a hash set for quick lookups - var normalizedTagsToAdd = new HashSet(tagNames.Select(t => t.ToNormalized())); + // Create a dictionary: normalized => original + var normalizedToOriginal = tagNames + .Select(t => new { Original = t, Normalized = t.ToNormalized() }) + .GroupBy(x => x.Normalized) // in case of duplicates + .ToDictionary(g => g.Key, g => g.First().Original); + + var normalizedTagsToAdd = new HashSet(normalizedToOriginal.Keys); var existingTagsSet = new HashSet(chapter.Tags.Select(t => t.NormalizedTitle)); var isModified = false; @@ -30,7 +36,7 @@ public static class TagHelper .Where(t => !normalizedTagsToAdd.Contains(t.NormalizedTitle)) .ToList(); - if (tagsToRemove.Any()) + if (tagsToRemove.Count != 0) { foreach (var tagToRemove in tagsToRemove) { @@ -47,7 +53,7 @@ public static class TagHelper // Find missing tags that are not already in the database var missingTags = normalizedTagsToAdd .Where(nt => !existingTagTitles.ContainsKey(nt)) - .Select(title => new TagBuilder(title).Build()) + .Select(nt => new TagBuilder(normalizedToOriginal[nt]).Build()) .ToList(); // Add missing tags to the database if any @@ -67,13 +73,11 @@ public static class TagHelper // Add the new or existing tags to the chapter foreach (var normalizedTitle in normalizedTagsToAdd) { - var tag = existingTagTitles[normalizedTitle]; + if (existingTagsSet.Contains(normalizedTitle)) continue; - if (!existingTagsSet.Contains(normalizedTitle)) - { - chapter.Tags.Add(tag); - isModified = true; - } + var tag = existingTagTitles[normalizedTitle]; + chapter.Tags.Add(tag); + isModified = true; } // Commit changes if modifications were made to the chapter's tags @@ -103,13 +107,18 @@ public static class TagHelper public static void UpdateTagList(ICollection? existingDbTags, Series series, IReadOnlyCollection newTags, Action handleAdd, Action onModified) + { + UpdateTagList((existingDbTags ?? []).Select(t => t.Title).ToList(), series, newTags, handleAdd, onModified); + } + + public static void UpdateTagList(ICollection? existingDbTags, Series series, IReadOnlyCollection newTags, Action handleAdd, Action onModified) { if (existingDbTags == null) return; var isModified = false; // Convert tags and existing genres to hash sets for quick lookups by normalized title - var existingTagSet = new HashSet(existingDbTags.Select(t => t.Title.ToNormalized())); + var existingTagSet = new HashSet(existingDbTags.Select(t => t.ToNormalized())); var dbTagSet = new HashSet(series.Metadata.Tags.Select(g => g.NormalizedTitle)); // Remove tags that are no longer present in the input tags @@ -129,7 +138,7 @@ public static class TagHelper // Add new tags from the input list foreach (var tagDto in existingDbTags) { - var normalizedTitle = tagDto.Title.ToNormalized(); + var normalizedTitle = tagDto.ToNormalized(); if (dbTagSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres @@ -139,7 +148,7 @@ public static class TagHelper } else { - handleAdd(new TagBuilder(tagDto.Title).Build()); // Add new genre if not found + handleAdd(new TagBuilder(tagDto).Build()); // Add new genre if not found } isModified = true; } @@ -150,5 +159,4 @@ public static class TagHelper onModified(); } } - } diff --git a/API/I18N/as.json b/API/I18N/as.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/API/I18N/as.json @@ -0,0 +1 @@ +{} diff --git a/API/I18N/ca.json b/API/I18N/ca.json new file mode 100644 index 000000000..b314a9374 --- /dev/null +++ b/API/I18N/ca.json @@ -0,0 +1,65 @@ +{ + "confirm-email": "Heu de confirmar l'adreça electrònica primer", + "invalid-password": "La contrasenya no és vàlida", + "nothing-to-do": "Res a fer", + "no-user": "El compte no existeix", + "invalid-token": "El testimoni no és vàlid", + "volume-num": "Volum {0}", + "book-num": "Llibre {0}", + "issue-num": "Número {0}{1}", + "chapter-num": "Capítol {0}", + "check-updates": "Comprova si hi ha actualitzacions", + "invalid-username": "El nom d'usuari no és vàlid", + "chapter-doesnt-exist": "El capítol no existeix", + "collection-updated": "S'ha actualitzat la col·lecció correctament", + "collection-deleted": "S'ha suprimit la col·lecció", + "collection-doesnt-exist": "La col·lecció no existeix", + "collection-already-exists": "La col·lecció ja existeix", + "person-doesnt-exist": "La persona no existeix", + "device-doesnt-exist": "El dispositiu no existeix", + "volume-doesnt-exist": "El volum no existeix", + "series-doesnt-exist": "La sèrie no existeix", + "no-cover-image": "No hi ha cap imatge de coberta", + "library-name-exists": "El nom de la biblioteca ja existeix. Trieu un nom únic per al servidor.", + "generic-library": "S'ha produït un error greu. Torneu-ho a provar.", + "invalid-filename": "El nom de fitxer no és vàlid", + "file-doesnt-exist": "El fitxer no existeix", + "user-doesnt-exist": "El compte no existeix", + "no-library-access": "El compte no té accés a aquesta biblioteca", + "library-doesnt-exist": "La biblioteca no existeix", + "invalid-path": "El camí no és vàlid", + "libraries": "Totes les biblioteques", + "browse-libraries": "Explora per biblioteques", + "collections": "Totes les col·leccions", + "browse-collections": "Explora per col·leccions", + "smart-filters": "Filtres intel·ligents", + "external-sources": "Fonts externes", + "browse-external-sources": "Explora les fonts externes", + "search": "Cerca", + "search-description": "Cerca sèries, col·leccions o llistes de lectura", + "external-source-required": "Cal la clau de l'API i l'amfitrió", + "smart-filter-doesnt-exist": "El filtre intel·ligent no existeix", + "collection-tag-duplicate": "Ja existeix una col·lecció amb aquest nom", + "device-duplicate": "Ja existeix un dispositiu amb aquest nom", + "send-to-permission": "No és possible enviar fitxers que no siguin EPUB o PDF perquè el Kindle no els admet", + "browse-smart-filters": "Explora per filtres intel·ligents", + "external-source-already-exists": "La font externa ja existeix", + "device-not-created": "Aquest dispositiu no existeix encara. Creeu-lo primer", + "external-source-doesnt-exist": "La font externa no existeix", + "backup": "Còpia de seguretat", + "file-missing": "No s'ha trobat el fitxer al llibre", + "reading-list-deleted": "S'ha suprimit la llista de lectura", + "generic-device-delete": "S'ha produït un error en suprimir el dispositiu", + "reading-list-position": "No s'ha pogut actualitzar la posició", + "generic-reading-list-delete": "S'ha produït un problema en suprimir la llista de lectura", + "generic-device-create": "S'ha produït un error en crear el dispositiu", + "generic-device-update": "S'ha produït un error en actualitzar el dispositiu", + "reading-list-doesnt-exist": "La llista de lectura no existeix", + "update-metadata-fail": "No s'han pogut actualitzar les metadades", + "reading-list-name-exists": "Ja existeix una llista de lectura amb aquest nom", + "ip-address-invalid": "L'adreça IP «{0}» no és vàlida", + "reading-list-item-delete": "No s'han pogut suprimir els elements", + "generic-reading-list-create": "S'ha produït un problema en crear la llista de lectura", + "generic-reading-list-update": "S'ha produït un problema en actualitzar la llista de lectura", + "generic-create-temp-archive": "S'ha produït un problema en crear l'arxiu temporal" +} diff --git a/API/I18N/cs.json b/API/I18N/cs.json index fa465b1fd..e136d8e75 100644 --- a/API/I18N/cs.json +++ b/API/I18N/cs.json @@ -187,7 +187,7 @@ "account-email-invalid": "Email v souboru účtu správce není platný email. Nelze poslat testovací email.", "email-settings-invalid": "Chybí informace o nastavení emailu. Ujistěte se, zda-li jsou uložena všechna nastavení.", "send-to-unallowed": "Nemůžete odeslat do zařízení které není vaše", - "send-to-size-limit": "Soubor(y) které se snažíte poslat jsou příliš velké pro vašeho email poskytovatele", + "send-to-size-limit": "Soubor(y) které se snažíte poslat jsou příliš velké pro vašeho poskytovatele emailu", "error-import-stack": "Při importu zásobníku MAL došlo k chybě", "collection-already-exists": "Kolekce již existuje", "generic-cover-person-save": "Nebylo možné uložit cover obrázek pro Osobu", @@ -197,5 +197,17 @@ "scan-libraries": "Skenovat Knihovny", "backup": "Záloha", "update-yearly-stats": "Aktualizovat roční statistiky", - "remove-from-want-to-read": "Vyčištění Chci si přečíst" + "remove-from-want-to-read": "Vyčištění Chci si přečíst", + "person-doesnt-exist": "Osoba neexistuje", + "person-name-required": "Jméno osoby je povinné a nesmí být prázdné", + "person-name-unique": "Jméno osoby musí být unikátní", + "person-image-doesnt-exist": "Osoba neexistuje v databázi CoversDB", + "email-taken": "Email je již používán", + "kavitaplus-restricted": "Toto je omezeno pouze na Kavita+", + "dashboard-stream-only-delete-smart-filter": "Z ovládacího panelu lze odstranit pouze streamy chytrých filtrů", + "smart-filter-name-required": "Vyžaduje se název chytrého filtru", + "smart-filter-system-name": "Nelze použít název streamu poskytovaného systémem", + "sidenav-stream-only-delete-smart-filter": "Z postranní navigace lze odstranit pouze streamy chytrých filtrů", + "aliases-have-overlap": "Jeden nebo více aliasů se překrývají s jinými osobami, nelze je aktualizovat", + "generated-reading-profile-name": "Generováno z {0}" } diff --git a/API/I18N/de.json b/API/I18N/de.json index 81aae4a8e..a6c865897 100644 --- a/API/I18N/de.json +++ b/API/I18N/de.json @@ -178,7 +178,7 @@ "unable-to-reset-k+": "Aufgrund eines Fehlers konnte die Kavita+ Lizenz nicht zurückgesetzt werden. Kontaktieren Sie den Kavita+ Support", "email-not-enabled": "Der Mailversand ist auf diesem Server nicht aktiviert. Sie können diese Aktion nicht durchführen.", "invalid-email": "Die für den Benutzer hinterlegte E-Mail ist ungültig. Links finden Sie in den Logs.", - "send-to-unallowed": "Sie können nicht an ein Gerät senden, das nicht Ihnen gehört.", + "send-to-unallowed": "Sie können nicht an ein Gerät senden, das nicht Ihnen gehört", "send-to-size-limit": "Die Datei(en), die Sie zu senden versuchen, sind zu groß für Ihren E-Mail-Anbieter", "check-updates": "Updates überprüfen", "email-settings-invalid": "E-Mail-Einstellungen fehlen Informationen. Stellen Sie sicher, dass alle E-Mail-Einstellungen gespeichert sind.", @@ -201,5 +201,13 @@ "person-doesnt-exist": "Die Person existiert nicht", "person-name-required": "Personenname ist erforderlich und darf nicht null sein", "person-name-unique": "Der Name der Person muss eindeutig sein", - "person-image-doesnt-exist": "Die Person existiert nicht in CoversDB" + "person-image-doesnt-exist": "Die Person existiert nicht in CoversDB", + "email-taken": "E-Mail bereits in Gebrauch", + "kavitaplus-restricted": "Dies ist nur auf Kavita+ beschränkt", + "sidenav-stream-only-delete-smart-filter": "Nur Smart-Filter-Streams können aus der Seitennavigation gelöscht werden", + "dashboard-stream-only-delete-smart-filter": "Nur Smart-Filter-Streams können aus dem Dashboard gelöscht werden", + "smart-filter-system-name": "Du kannst den Namen eines vom System bereitgestellten Streams nicht verwenden", + "smart-filter-name-required": "Name des Smart Filters erforderlich", + "aliases-have-overlap": "Ein oder mehrere Aliasnamen sind mit anderen Personen identisch und können nicht aktualisiert werden", + "generated-reading-profile-name": "Erstellt aus {0}" } diff --git a/API/I18N/en.json b/API/I18N/en.json index 418427111..d3cd1ecd3 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -186,6 +186,10 @@ "external-source-required": "ApiKey and Host required", "external-source-doesnt-exist": "External Source doesn't exist", "external-source-already-in-use": "There is an existing stream with this External Source", + "sidenav-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the SideNav", + "dashboard-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the dashboard", + "smart-filter-name-required": "Smart Filter name required", + "smart-filter-system-name": "You cannot use the name of a system provided stream", "not-authenticated": "User is not authenticated", "unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support", @@ -207,6 +211,8 @@ "reading-list-name-exists": "A reading list of this name already exists", "user-no-access-library-from-series": "User does not have access to the library this series belongs to", "series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions", + "kavitaplus-restricted": "This is restricted to Kavita+ only", + "aliases-have-overlap": "One or more of the aliases have overlap with other people, cannot update", "volume-num": "Volume {0}", "book-num": "Book {0}", @@ -224,6 +230,8 @@ "scan-libraries": "Scan Libraries", "kavita+-data-refresh": "Kavita+ Data Refresh", "backup": "Backup", - "update-yearly-stats": "Update Yearly Stats" + "update-yearly-stats": "Update Yearly Stats", + + "generated-reading-profile-name": "Generated from {0}" } diff --git a/API/I18N/es.json b/API/I18N/es.json index 2440d1c66..ca1a5c38a 100644 --- a/API/I18N/es.json +++ b/API/I18N/es.json @@ -22,7 +22,7 @@ "bookmarks-empty": "Los marcadores no pueden estar vacíos", "must-be-defined": "{0} debe estar definido", "invalid-filename": "Nombre de archivo no válido", - "library-name-exists": "El nombre de la biblioteca ya existe. Por favor, elige un nombre único.", + "library-name-exists": "El nombre de la biblioteca ya existe. Elija un nombre unívoco para el servidor.", "user-doesnt-exist": "El usuario no existe", "library-doesnt-exist": "La biblioteca no existe", "age-restriction-update": "Ha ocurrido un error al actualizar la restricción de edad", @@ -38,11 +38,11 @@ "generic-device-create": "Ha ocurrido un error al crear el dispositivo", "greater-0": "{0} debe ser mayor que 0", "send-to-kavita-email": "Enviar al dispositivo no se puede utilizar sin configurar el correo electrónico", - "no-cover-image": "No hay imagen de portada", + "no-cover-image": "No hay imagen de cubierta", "bookmark-doesnt-exist": "El marcador no existe", "generic-favicon": "Ha ocurrido un error al obtener el icono para el dominio", "file-doesnt-exist": "El archivo no existe", - "generic-library": "Ha ocurrido un error fatal. Por favor, inténtalo de nuevo.", + "generic-library": "Ha ocurrido un error grave. Inténtelo de nuevo.", "no-library-access": "El usuario no tiene acceso a esta biblioteca", "no-user": "El usuario no existe", "username-taken": "El nombre de usuario ya existe", @@ -87,7 +87,7 @@ "update-metadata-fail": "No se han podido actualizar los metadatos", "generic-relationship": "Hubo un problema al actualizar las relaciones", "job-already-running": "Trabajo ya en ejecución", - "ip-address-invalid": "La dirección IP '{0}' no es válida", + "ip-address-invalid": "La dirección IP «{0}» no es válida", "bookmark-dir-permissions": "El directorio de marcadores no tiene los permisos correctos para que Kavita pueda utilizarlo", "total-backups": "El número total de copias de seguridad debe estar entre 1 y 30", "stats-permission-denied": "No está autorizado a ver las estadísticas de otro usuario", @@ -116,18 +116,18 @@ "generic-create-temp-archive": "Hubo un problema al crear un archivo temporal", "epub-malformed": "¡El archivo está malformado! No se puede leer.", "book-num": "Libro {0}", - "issue-num": "Incidencia {0}{1}", + "issue-num": "Número {0}{1}", "search-description": "Buscar series, colecciones o listas de lectura", "unable-to-register-k+": "No se ha podido registrar la licencia debido a un error. Póngase en contacto con el servicio de asistencia de Kavita", "bad-copy-files-for-download": "No se pueden copiar archivos al directorio temporal de descarga de archivos.", - "send-to-permission": "No se puede enviar archivos que no sean EPUB o PDF a dispositivos no compatibles con Kindle", + "send-to-permission": "No se pueden enviar archivos que no sean EPUB o PDF a los dispositivos porque Kindle no los admite", "progress-must-exist": "El progreso debe existir en el usuario", "epub-html-missing": "No se ha podido encontrar el HTML apropiado para esa página", "collection-tag-duplicate": "Ya existe una colección con este nombre", "device-duplicate": "Ya existe un dispositivo con este nombre", "collection-tag-title-required": "El título de la colección no puede estar vacío", "reading-list-title-required": "El título de la lista de lectura no puede estar vacío", - "device-not-created": "Este dispositivo aún no existe. Por favor, créelo primero", + "device-not-created": "Este dispositivo aún no existe. Créelo primero", "reading-list-name-exists": "Ya existe una lista de lectura con este nombre", "user-no-access-library-from-series": "El usuario no tiene acceso a la biblioteca a la que pertenece esta serie", "series-restricted-age-restriction": "El usuario no puede ver esta serie debido a restricciones de edad", @@ -169,7 +169,7 @@ "sidenav-stream-doesnt-exist": "SideNav Stream no existe", "external-source-doesnt-exist": "La fuente externa no existe", "external-sources": "Fuentes externas", - "external-source-required": "Se requiere la clave API y el host", + "external-source-required": "Se requiere la clave de API y el anfitrión", "smart-filter-already-in-use": "Existe una transmisión con este filtro inteligente", "invalid-email": "La dirección de correo electrónico del usuario no es válida. Consulte los registros para ver si hay algún enlace.", "browse-more-in-genre": "Ver más en {0}", @@ -177,7 +177,7 @@ "recently-updated": "Actualizado recientemente", "browse-recently-updated": "Examinar las últimas actualizaciones", "unable-to-reset-k+": "No se ha podido restablecer la licencia de Kavita+ debido a un error. Contacta con el soporte de Kavita", - "send-to-unallowed": "No puedes enviar a un dispositivo que no sea el tuyo", + "send-to-unallowed": "No puede enviar a un dispositivo que no sea el suyo", "email-not-enabled": "El correo electrónico no está habilitado en este servidor. No puede realizar esta acción.", "send-to-size-limit": "El(Los) archivo(s) que intenta enviar es(son) demasiado(s) grande(s) para su proveedor de correo electrónico", "process-scrobbling-events": "Procesar eventos de scrobbling", @@ -187,7 +187,7 @@ "cleanup": "Limpieza", "remove-from-want-to-read": "Eliminar de querer leer", "kavita+-data-refresh": "Actualización de los datos de Kavita+", - "backup": "Copia de seguridad", + "backup": "Copia de respaldo", "update-yearly-stats": "Actualizar estadísticas anualmente", "license-check": "Comprobar la licencia", "scan-libraries": "Escanear la biblioteca", @@ -201,5 +201,7 @@ "person-doesnt-exist": "No existe la persona", "person-name-required": "El nombre de la persona es obligatorio y no debe estar vacío", "person-name-unique": "El nombre de la persona debe ser único", - "person-image-doesnt-exist": "La persona no existe en CoversDB" + "person-image-doesnt-exist": "La persona no existe en CoversDB", + "email-taken": "El correo electrónico ya está en uso", + "kavitaplus-restricted": "Esto está restringido a Kavita+" } diff --git a/API/I18N/fa.json b/API/I18N/fa.json new file mode 100644 index 000000000..0933c41d5 --- /dev/null +++ b/API/I18N/fa.json @@ -0,0 +1,7 @@ +{ + "validate-email": "در اعتبارسنجی ایمیل شما مشکلی رخ داد: {0}", + "confirm-email": "اول ایمیل خود را تایید کنید", + "locked-out": "شما به علت تلاش‌های ورود بیش از حد، محدود شدید. لطفاً ۱۰ دقیقه صبر کنید.", + "disabled-account": "حساب شما غیرفعال شده است ، لطفاً با مدیر سرور تماس حاصل کنید.", + "register-user": "در هنگام ثبت کاربر مشکلی رخ داد." +} diff --git a/API/I18N/fi.json b/API/I18N/fi.json index 8cea1b80a..dd2a3f070 100644 --- a/API/I18N/fi.json +++ b/API/I18N/fi.json @@ -10,7 +10,7 @@ "generic-device-update": "Laitteen päivittämisessä tapahtui virhe", "send-to-kavita-email": "Laitteelle lähettämistä ei voi käyttää ilman sähköpostin asetuksia", "send-to-unallowed": "Et voi lähettää laitteeseen, joka ei ole sinun", - "send-to-size-limit": "Tiedostot, joita yrität lähettää, ovat liian suuria sähköpostiisi", + "send-to-size-limit": "Tiedostot, joita yrität lähettää, ovat liian suuria sähköpostipalveluntarjoajallesi", "send-to-device-status": "Tiedostoja siirretään laitteellesi", "generic-send-to": "Tiedoston / tiedostojen lähettämisessä tapahtui virhe", "no-cover-image": "Ei kansikuvaa", @@ -42,8 +42,8 @@ "encode-as-warning": "Et voi muuttaa PNG:ksi. Kansikuville, kokeile päivitystä. Kirjamerkkejä ja faviconeja ei voi koodata takaisin.", "ip-address-invalid": "IP-osoite '{0}' on virheellinen", "bookmark-dir-permissions": "Kirjanmerkki hakemistolla ei ole oikeita käyttöoikeuksia Kavitalle", - "total-backups": "Varmuuskopioiden on oltava 1-30", - "total-logs": "Kokonaislokien on oltava 1-30", + "total-backups": "Varmuuskopioiden kokonaismäärän on oltava välillä 1–30", + "total-logs": "Lokien kokonaismäärän on oltava välillä 1–30", "stats-permission-denied": "Sinulla ei ole oikeutta katsoa toisen käyttäjän tilastoja", "url-not-valid": "Url ei palauta kelvollista kuvaa tai vaatii luvan", "url-required": "Sinun on annattava url käyttääksesi", @@ -77,7 +77,7 @@ "disabled-account": "Sinun tilisi on poistettu käytöstä. Ota yhteyttä palvelimen ylläpitäjään.", "register-user": "Jokin meni pieleen, kun käyttäjä rekisteröityi", "validate-email": "Sähköpostin vahvistamisessa oli ongelma: {0}", - "confirm-token-gen": "Vahvistus merkin tuottamisessa oli ongelma", + "confirm-token-gen": "Vahvistuspoletin luomisessa oli ongelma", "denied": "Ei sallittu", "permission-denied": "Tämä toiminto on sinulta kielletty", "invalid-password": "Virheellinen Salasana", @@ -170,5 +170,10 @@ "user-no-access-library-from-series": "Käyttäjällä ei ole pääsyä kirjastoon, johon sarja kuuluu", "chapter-num": "Luku {0}", "cleanup": "Puhdistus", - "browse-reading-lists": "Selaa Lukulistoja" + "browse-reading-lists": "Selaa Lukulistoja", + "person-doesnt-exist": "Henkilöä ei ole olemassa", + "person-name-required": "Henkilön nimi on pakollinen, eikä se saa olla tyhjä", + "person-name-unique": "Henkilön nimen on oltava yksilöllinen", + "person-image-doesnt-exist": "Henkilöä ei ole olemassa CoversDB:ssa", + "email-taken": "Sähköposti jo käytössä" } diff --git a/API/I18N/fr.json b/API/I18N/fr.json index feecc39d6..2b9a4f81b 100644 --- a/API/I18N/fr.json +++ b/API/I18N/fr.json @@ -201,5 +201,13 @@ "person-name-unique": "Le nom de la personne doit être unique", "person-image-doesnt-exist": "La personne n'existe pas dans CoversDB", "person-doesnt-exist": "La personne n'existe pas", - "person-name-required": "Le nom de la personne est obligatoire et ne doit pas être nul" + "person-name-required": "Le nom de la personne est obligatoire et ne doit pas être nul", + "email-taken": "Email déjà existant", + "kavitaplus-restricted": "Ce service est réservé à Kavita+", + "sidenav-stream-only-delete-smart-filter": "Seuls les flux de filtres intelligents peuvent être supprimés de la SideNav", + "dashboard-stream-only-delete-smart-filter": "Seuls les flux de filtres intelligents peuvent être supprimés du tableau de bord", + "smart-filter-name-required": "Nom du filtre intelligent requis", + "smart-filter-system-name": "Vous ne pouvez pas utiliser le nom d'un flux fourni par le système", + "aliases-have-overlap": "Un ou plusieurs alias se chevauchent avec d'autres personnes et ne peuvent pas être mis à jour", + "generated-reading-profile-name": "Généré à partir de {0}" } diff --git a/API/I18N/ga.json b/API/I18N/ga.json index bef3c436b..142425aec 100644 --- a/API/I18N/ga.json +++ b/API/I18N/ga.json @@ -201,5 +201,13 @@ "person-doesnt-exist": "Níl duine ann", "person-name-required": "Tá ainm an duine ag teastáil agus ní féidir é a bheith ar neamhní", "person-name-unique": "Caithfidh ainm duine a bheith uathúil", - "person-image-doesnt-exist": "Níl an duine in CoversDB" + "person-image-doesnt-exist": "Níl an duine in CoversDB", + "email-taken": "Ríomhphost in úsáid cheana féin", + "kavitaplus-restricted": "Tá sé seo teoranta do Kavita+ amháin", + "smart-filter-system-name": "Ní féidir leat ainm srutha an chórais a sholáthair tú a úsáid", + "sidenav-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh as an SideNav", + "dashboard-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh ón deais", + "smart-filter-name-required": "Ainm Scagaire Cliste ag teastáil", + "aliases-have-overlap": "Tá forluí idir ceann amháin nó níos mó de na leasainmneacha agus daoine eile, ní féidir iad a nuashonrú", + "generated-reading-profile-name": "Gineadh ó {0}" } diff --git a/API/I18N/he.json b/API/I18N/he.json index 41a9a7de7..3b2386bf6 100644 --- a/API/I18N/he.json +++ b/API/I18N/he.json @@ -21,5 +21,6 @@ "age-restriction-update": "אירעה תקלה בעת עדכון הגבלת גיל", "generic-user-update": "אירעה תקלה בעת עדכון משתמש", "user-already-registered": "משתמש רשום כבר בתור {0}", - "manual-setup-fail": "לא מתאפשר להשלים הגדרה ידנית. יש לבטל וליצור מחדש את ההזמנה" + "manual-setup-fail": "לא מתאפשר להשלים הגדרה ידנית. יש לבטל וליצור מחדש את ההזמנה", + "email-taken": "דואר אלקטרוני כבר בשימוש" } diff --git a/API/I18N/hu.json b/API/I18N/hu.json index 7c9473116..21649e2ce 100644 --- a/API/I18N/hu.json +++ b/API/I18N/hu.json @@ -196,5 +196,9 @@ "check-scrobbling-tokens": "Ellenőrizd a Feldolgozó tokeneket", "process-scrobbling-events": "Feldolgozó események feldolgozása", "process-processed-scrobbling-events": "A feldolgozott Feldolgozó események felolgozása", - "generic-cover-volume-save": "Nem lehet borítóképet menteni a kötethez" + "generic-cover-volume-save": "Nem lehet borítóképet menteni a kötethez", + "person-doesnt-exist": "A személy nem létezik", + "person-name-required": "A személy neve kötelező, és nem lehet üres", + "email-taken": "Az e-mail már használatban van", + "person-name-unique": "A személy nevének egyedinek kell lennie" } diff --git a/API/I18N/it.json b/API/I18N/it.json index a5f8cbf31..cf43101a6 100644 --- a/API/I18N/it.json +++ b/API/I18N/it.json @@ -90,7 +90,7 @@ "user-no-access-library-from-series": "L'utente non ha accesso alla libreria a cui appartiene questa serie", "volume-num": "Volume {0}", "book-num": "Libro {0}", - "issue-num": "Problema {0}{1}", + "issue-num": "Numero {0}{1}", "chapter-num": "Capitolo {0}", "epub-malformed": "Il file è corrotto! Non posso leggere.", "collection-updated": "Collezione aggiornata con successo", @@ -178,7 +178,7 @@ "browse-recently-updated": "Sfoglia gli aggiornamenti recenti", "email-not-enabled": "L'email non è attivata in questo server. Non puoi compiere questa azione.", "send-to-unallowed": "Non puoi inviare ad un dispositivo non tuo", - "send-to-size-limit": "Il/I file che stai cercando di mandare sono troppo grandi per l'email", + "send-to-size-limit": "Il/I file che stai cercando di mandare sono troppo grandi per il tuo provider email", "unable-to-reset-k+": "Impossibile ripristinare la licenza Kavita+. Contatta il supporto Kavita+", "check-updates": "Controlla aggiornamenti", "license-check": "Controlla Licenza", @@ -193,5 +193,15 @@ "kavita+-data-refresh": "Aggiornamento dati Kavita+", "backup": "Backup", "account-email-invalid": "L'e-mail in archivio per l'account amministratore non è un'e-mail valida. Impossibile inviare e-mail di prova.", - "email-settings-invalid": "Informazioni mancanti nelle impoastazioni email. Assicurati che tutte le impostazioni email siano salvate." + "email-settings-invalid": "Informazioni mancanti nelle impoastazioni email. Assicurati che tutte le impostazioni email siano salvate.", + "person-doesnt-exist": "La persona non esiste", + "email-taken": "Email già in uso", + "person-name-required": "Il nome della persona è richiesto e non può essere null", + "person-name-unique": "Il nome della persona deve essere univoco", + "person-image-doesnt-exist": "La persona non esiste su CoversDB", + "collection-already-exists": "La collezione esiste già", + "generic-cover-volume-save": "Impossibile salvare l'immagine di copertina nel Volume", + "generic-cover-person-save": "Impossibile salvare l'immagine di copertina nella Persona", + "error-import-stack": "Si è verificato un errore durante l'importazione dello stack MAL", + "kavitaplus-restricted": "Riservato a Kavita+" } diff --git a/API/I18N/ja.json b/API/I18N/ja.json index ba6b6e8e7..07efb40ef 100644 --- a/API/I18N/ja.json +++ b/API/I18N/ja.json @@ -95,7 +95,7 @@ "bookmark-permission": "ブックマーク/ブックマークを解除する権限があなたにはありません。", "valid-number": "有効なページ番号である必要があります", "duplicate-bookmark": "重複したブックマークエントリーがすでに存在します", - "send-to-size-limit": "送信しようとしているファイルはメーラーにとっては大きすぎます。", + "send-to-size-limit": "送信しようとしているファイルはあなたのメールプロバイダにとっては大きすぎます。", "series-doesnt-exist": "シリーズが存在しません", "pdf-doesnt-exist": "PDFが存在すべきですが存在しません。", "generic-reading-list-create": "リーディングリストを作成している際に問題が発生しました", @@ -180,9 +180,16 @@ "generic-create-temp-archive": "一時アーカイブの作成中に問題が発生しました", "user-no-access-library-from-series": "ユーザーは、このシリーズが所属するライブラリにアクセス権限がありません", "collection-tag-duplicate": "この名前のコレクションは既に存在しています", - "account-email-invalid": "管理者アカウントに登録されている電子メールは有効な電子メールではありません。 テストメールを送信できませんでした。", + "account-email-invalid": "管理者アカウントに登録されている電子メールは有効な電子メールではありません。 テストメールを送信できません。", "check-updates": "アップデートをチェックする", "license-check": "ライセンスを確認", "collection-already-exists": "コレクションは既に存在しています", - "email-settings-invalid": "メール設定に不足している情報があります。すべてのメール設定が保存されていることを確認してください。" + "email-settings-invalid": "メール設定に不足している情報があります。すべてのメール設定が保存されていることを確認してください。", + "email-taken": "メールアドレスは既に使われています", + "person-doesnt-exist": "人物は存在しません", + "person-name-unique": "人名は一意でなければなりません", + "person-name-required": "人物の名前は必須であり、空にすることはできません", + "person-image-doesnt-exist": "人物はCoversDBに存在しません", + "generic-cover-person-save": "カバー画像を人物に保存できません", + "generic-cover-volume-save": "カバー画像を巻に保存できません" } diff --git a/API/I18N/ko.json b/API/I18N/ko.json index d0963908e..7fec9f60a 100644 --- a/API/I18N/ko.json +++ b/API/I18N/ko.json @@ -201,5 +201,13 @@ "person-doesnt-exist": "사람이 존재하지 않습니다", "person-name-required": "개인 이름은 필수 항목이며 null일 수 없습니다", "person-name-unique": "개인 이름은 고유해야 합니다", - "person-image-doesnt-exist": "CoversDB에 사람이 존재하지 않습니다" + "person-image-doesnt-exist": "CoversDB에 사람이 존재하지 않습니다", + "kavitaplus-restricted": "Kavita+만 해당", + "email-taken": "이미 사용중인 이메일", + "dashboard-stream-only-delete-smart-filter": "대시보드에서 스마트 필터 스트림만 삭제할 수 있습니다", + "sidenav-stream-only-delete-smart-filter": "사이드 메뉴에서 스마트 필터 스트림만 삭제할 수 있습니다", + "smart-filter-name-required": "스마트 필터 이름이 필요합니다", + "smart-filter-system-name": "시스템 제공 스트림 이름은 사용할 수 없습니다", + "aliases-have-overlap": "하나 이상의 별명이 다른 사용자와 중복되어 업데이트할 수 없습니다", + "generated-reading-profile-name": "{0}(으)로부터 생성됨" } diff --git a/API/I18N/pl.json b/API/I18N/pl.json index e537729f2..5372fddc0 100644 --- a/API/I18N/pl.json +++ b/API/I18N/pl.json @@ -201,5 +201,13 @@ "person-doesnt-exist": "Osoba nie istnieje", "person-name-required": "Nazwa osoby jest wymagana i nie może mieć wartości null", "person-name-unique": "Nazwa osoby musi być unikatowa", - "person-image-doesnt-exist": "Osoba nie istnieje w CoversDB" + "person-image-doesnt-exist": "Osoba nie istnieje w CoversDB", + "email-taken": "Adres e-mail jest już używany", + "kavitaplus-restricted": "Jest to dostępne tylko dla Kavita+", + "smart-filter-name-required": "Inteligentny filtr wymaga nazwy", + "sidenav-stream-only-delete-smart-filter": "Tylko inteligentne filtry mogą zostać usunięte z panelu bocznego", + "dashboard-stream-only-delete-smart-filter": "Tylko inteligentne strumienie filtrów może zostać usunięte z głównego panelu", + "smart-filter-system-name": "Nie można użyć nazwy systemu dostarczanego strumieniem", + "aliases-have-overlap": "Jeden lub więcej aliasów pokrywa się z innymi osobami, nie można ich zaktualizować", + "generated-reading-profile-name": "Wygenerowano z {0}" } diff --git a/API/I18N/pt.json b/API/I18N/pt.json index 72c58771a..726c843bb 100644 --- a/API/I18N/pt.json +++ b/API/I18N/pt.json @@ -201,5 +201,13 @@ "person-doesnt-exist": "Pessoa não existe", "person-name-required": "O nome da pessoa é obrigatório e não pode nulo", "person-name-unique": "O nome da pessoa tem de ser único", - "person-image-doesnt-exist": "A pessoa não existe na CoversDB" + "person-image-doesnt-exist": "A pessoa não existe na CoversDB", + "email-taken": "Email já em uso", + "kavitaplus-restricted": "Ação restrita ao Kavita+", + "sidenav-stream-only-delete-smart-filter": "Apenas os filtros inteligentes podem ser removidos da Navegação Lateral", + "dashboard-stream-only-delete-smart-filter": "Apenas os filtros inteligentes podem ser removidos do painel", + "smart-filter-system-name": "Não pode usar o nome de um fluxo disponibilizado pelo sistema", + "smart-filter-name-required": "Nome requerido para o filtro inteligente", + "aliases-have-overlap": "Um ou mais pseudónimos sobrepõem-se com outras pessoas, não vai ser possível atualizar", + "generated-reading-profile-name": "Gerado de {0}" } diff --git a/API/I18N/pt_BR.json b/API/I18N/pt_BR.json index 0bd4fecbc..418e0ea3b 100644 --- a/API/I18N/pt_BR.json +++ b/API/I18N/pt_BR.json @@ -201,5 +201,13 @@ "person-doesnt-exist": "Pessoa não existe", "person-image-doesnt-exist": "A pessoa não existe no CoversDB", "person-name-required": "O nome da pessoa é obrigatório e não deve ser nulo", - "person-name-unique": "O nome da pessoa deve ser exclusivo" + "person-name-unique": "O nome da pessoa deve ser exclusivo", + "email-taken": "E-mail já em uso", + "kavitaplus-restricted": "Isso é restrito apenas ao Kavita+", + "smart-filter-name-required": "Nome do Filtro Inteligente obrigatório", + "dashboard-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do painel", + "smart-filter-system-name": "Você não pode usar o nome de um fluxo fornecido pelo sistema", + "sidenav-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do Navegador Lateral", + "aliases-have-overlap": "Um ou mais dos pseudônimos se sobrepõem a outras pessoas, não pode atualizar", + "generated-reading-profile-name": "Gerado a partir de {0}" } diff --git a/API/I18N/ru.json b/API/I18N/ru.json index c75f58fb1..fdea5920f 100644 --- a/API/I18N/ru.json +++ b/API/I18N/ru.json @@ -1,5 +1,5 @@ { - "confirm-email": "Вы обязаны сначала подтвердить свою почту", + "confirm-email": "Сначала Вы обязаны подтвердить свою электронную почту", "generate-token": "Возникла проблема с генерацией токена подтверждения электронной почты. Смотрите журналы", "invalid-password": "Неверный пароль", "invalid-email-confirmation": "Неверное подтверждение электронной почты", @@ -35,15 +35,15 @@ "no-user": "Пользователь не существует", "generic-invite-user": "Возникла проблема с приглашением пользователя. Пожалуйста, проверьте журналы.", "permission-denied": "Вам запрещено выполнять эту операцию", - "invalid-access": "Недопустимый доступ", + "invalid-access": "В доступе отказано", "reading-list-name-exists": "Такой список для чтения уже существует", "perform-scan": "Пожалуйста, выполните сканирование этой серии или библиотеки и повторите попытку", "generic-device-create": "При создании устройства возникла ошибка", "generic-read-progress": "Возникла проблема с сохранением прогресса", "file-doesnt-exist": "Файл не существует", "admin-already-exists": "Администратор уже существует", - "send-to-kavita-email": "Отправка на устройство не может быть использована с почтовым сервисом Kavita. Пожалуйста, настройте свой собственный.", - "no-image-for-page": "Нет такого изображения для страницы {0}. Попробуйте обновить, чтобы обновить кэш.", + "send-to-kavita-email": "Отправка на устройство не может быть использована без настройки электронной почты", + "no-image-for-page": "Нет такого изображения для страницы {0}. Попробуйте обновить, для повторного кеширования.", "reading-list-permission": "У вас нет прав на этот список чтения или список не существует", "volume-doesnt-exist": "Том не существует", "generic-library": "Возникла критическая проблема. Пожалуйста, попробуйте еще раз.", @@ -57,7 +57,7 @@ "generic-reading-list-create": "Возникла проблема с созданием списка для чтения", "no-cover-image": "Изображение на обложке отсутствует", "collection-updated": "Коллекция успешно обновлена", - "critical-email-migration": "Возникла проблема при переносе электронной почты. Обратитесь в службу поддержки", + "critical-email-migration": "Возникла проблема при смене электронной почты. Обратитесь в службу поддержки", "cache-file-find": "Не удалось найти изображение в кэше. Перезагрузитесь и попробуйте снова.", "duplicate-bookmark": "Дублирующая закладка уже существует", "collection-tag-duplicate": "Такая коллекция уже существует", @@ -72,7 +72,7 @@ "pdf-doesnt-exist": "PDF не существует, когда он должен существовать", "generic-device-delete": "При удалении устройства возникла ошибка", "bookmarks-empty": "Закладки не могут быть пустыми", - "valid-number": "Должен быть действительный номер страницы", + "valid-number": "Номер страницы должен быть действительным", "series-doesnt-exist": "Серия не существует", "no-library-access": "Пользователь не имеет доступа к этой библиотеке", "reading-list-item-delete": "Не удалось удалить элемент(ы)", @@ -121,7 +121,7 @@ "opds-disabled": "OPDS не включен на этом сервере", "stats-permission-denied": "Вы не имеете права просматривать статистику другого пользователя", "reading-list-restricted": "Список чтения не существует или у вас нет доступа", - "favicon-doesnt-exist": "Фавикон не существует", + "favicon-doesnt-exist": "Favicon не существует", "external-source-already-in-use": "Существует поток с этим внешним источником", "issue-num": "Вопрос {0}{1}", "generic-create-temp-archive": "Возникла проблема с созданием временного архива", @@ -155,7 +155,7 @@ "generic-user-delete": "Не удалось удалить пользователя", "generic-cover-reading-list-save": "Невозможно сохранить изображение обложки в списке для чтения", "unable-to-register-k+": "Невозможно зарегистрировать лицензию из-за ошибки. Обратитесь в службу поддержки Кавита+", - "encode-as-warning": "Конвертировать в PNG невозможно. Для обложек используйте Обновить Обложки. Закладки и фавиконки нельзя закодировать обратно.", + "encode-as-warning": "Вы не можете конвертировать в формат PNG. Для обновления обложек используйте команду \"Обновить обложку\". Закладки и значки не могут быть закодированы обратно.", "want-to-read": "Хотите прочитать", "generic-user-pref": "Возникла проблема с сохранением предпочтений", "external-sources": "Внешние источники", @@ -194,5 +194,13 @@ "backup": "Резервное копирование", "process-processed-scrobbling-events": "Обработка обработанных событий скроблинга", "scan-libraries": "Сканирование библиотек", - "kavita+-data-refresh": "Обновление данных Kavita+" + "kavita+-data-refresh": "Обновление данных Kavita+", + "kavitaplus-restricted": "Это доступно только для Kavita+", + "person-doesnt-exist": "Персона не существует", + "generic-cover-volume-save": "Не удается сохранить обложку для тома", + "generic-cover-person-save": "Не удается сохранить изображение обложки для Персоны", + "person-name-unique": "Имя персоны должно быть уникальным", + "person-image-doesnt-exist": "Персона не существует в CoversDB", + "email-taken": "Почта уже используется", + "person-name-required": "Имя персоны обязательно и не может быть пустым" } diff --git a/API/I18N/sk.json b/API/I18N/sk.json index 0967ef424..ef267ed02 100644 --- a/API/I18N/sk.json +++ b/API/I18N/sk.json @@ -1 +1,213 @@ -{} +{ + "disabled-account": "Váš účet je deaktivovaný. Kontaktujte správcu servera.", + "register-user": "Niečo sa pokazilo pri registrácii užívateľa", + "confirm-email": "Najprv musíte potvrdiť svoj e-mail", + "locked-out": "Boli ste zamknutí z dôvodu veľkého počtu neúspešných pokusov o prihlásenie. Počkajte 10 minút.", + "validate-email": "Pri validácii vášho e-mailu sa vyskytla chyba: {0}", + "confirm-token-gen": "Pri vytváraní potvrdzovacieho tokenu sa vyskytla chyba", + "permission-denied": "Na vykonanie tejto úlohy nemáte oprávnenie", + "password-required": "Ak nie ste administrátor, musíte na vykonanie zmien vo vašom profile zadať vaše aktuálne heslo", + "invalid-password": "Nesprávne heslo", + "invalid-token": "Nesprávny token", + "unable-to-reset-key": "Niečo sa pokazilo, kľúč nie je možné resetovať", + "invalid-payload": "Nesprávny payload", + "nothing-to-do": "Nič na vykonanie", + "share-multiple-emails": "Nemôžete zdielať e-maily medzi rôznymi účtami", + "generate-token": "Pri generovaní potvrdzovacieho tokenu e-mailu sa vyskytla chyba. Pozrite záznamy udalostí", + "age-restriction-update": "Pri aktualizovaní vekového obmedzenia sa vyskytla chyba", + "no-user": "Používateľ neexistuje", + "generic-user-update": "Aktualizácia používateľa prebehla s výnimkou", + "username-taken": "Používateľské meno už existuje", + "user-already-confirmed": "Používateľ je už potvrdený", + "user-already-registered": "Používateľ je už registrovaný ako {0}", + "user-already-invited": "Používateľ je už pod týmto e-mailom pozvaný a musí ešte prijať pozvanie.", + "generic-password-update": "Pri potvrdení nového hesla sa vyskytla neočakávaná chyba", + "generic-invite-user": "Pri pozývaní tohto používateľa sa vyskytla chyba. Pozrite záznamy udalostí.", + "password-updated": "Heslo aktualizované", + "forgot-password-generic": "E-mail bude odoslaný na zadanú adresu len v prípade, ak existuje v databáze", + "invalid-email-confirmation": "Neplatné potvrdenie e-mailu", + "not-accessible-password": "Váš server nie je dostupný. Odkaz na resetovanie vášho hesla je v záznamoch udalostí", + "email-taken": "Zadaný e-mail už existuje", + "denied": "Nepovolené", + "manual-setup-fail": "Manuálne nastavenie nie je možné dokončiť. Prosím zrušte aktuálny postup a znovu vytvorte pozvánku", + "generic-user-email-update": "Nemožno aktualizovať e-mail používateľa. Skontrolujte záznamy udalostí.", + "email-not-enabled": "E-mail nie je na tomto serveri povolený. Preto túto akciu nemôžete vykonať.", + "collection-updated": "Zbierka bola úspešne aktualizovaná", + "device-doesnt-exist": "Zariadenie neexistuje", + "generic-device-delete": "Pri odstraňovaní zariadenia sa vyskytla chyba", + "greater-0": "{0} musí byť väčší ako 0", + "send-to-size-limit": "Snažíte sa odoslať súbor(y), ktoré sú príliš veľké pre vášho e-mailového poskytovateľa", + "send-to-device-status": "Prenos súborov do vášho zariadenia", + "no-cover-image": "Žiadny prebal", + "must-be-defined": "{0} musí byť definovaný", + "generic-favicon": "Pri získavaní favicon-u domény sa vyskytla chyba", + "no-library-access": "Pozužívateľ nemá prístup do tejto knižnice", + "user-doesnt-exist": "Používateľ neexistuje", + "collection-already-exists": "Zbierka už existuje", + "not-accessible": "Váš server nie je dostupný z vonkajšieho prostredia", + "email-sent": "E-mail odoslaný", + "user-migration-needed": "Uvedený používateľ potrebuje migrovať. Odhláste ho a opäť prihláste na spustenie migrácie", + "generic-invite-email": "Pri opakovanom odosielaní pozývacieho e-mailu sa vyskytla chyba", + "email-settings-invalid": "V nastaveniach e-mailu chýbajú potrebné údaje. Uistite sa, že všetky nastavenia e-mailu sú uložené.", + "chapter-doesnt-exist": "Kapitola neexistuje", + "critical-email-migration": "Počas migrácie e-mailu sa vyskytla chyba. Kontaktujte podporu", + "collection-deleted": "Zbierka bola vymazaná", + "generic-error": "Niečo sa pokazilo, skúste to znova", + "collection-doesnt-exist": "Zbierka neexistuje", + "generic-device-update": "Pri aktualizácii zariadenia sa vyskytla chyba", + "bookmark-doesnt-exist": "Záložka neexistuje", + "person-doesnt-exist": "Osoba neexistuje", + "send-to-kavita-email": "Odoslanie do zariadenia nemôže byť použité bez nastavenia e-amilu", + "send-to-unallowed": "Nemôžete odosielať do zariadenia, ktoré nie je vaše", + "generic-library": "Vyskytla sa kritická chyba. Prosím skúste to opäť.", + "pdf-doesnt-exist": "PDF neexistuje, hoci by malo", + "generic-library-update": "Počas aktualizácie knižnice sa vyskytla kritická chyba.", + "invalid-access": "Neplatný prístup", + "perform-scan": "Prosím, vykonajte opakovaný sken na tejto sérii alebo knižnici", + "generic-read-progress": "Pri ukladaní aktuálneho stavu sa vyskytla chyba", + "generic-clear-bookmarks": "Záložky nie je možné vymazať", + "bookmark-permission": "Nemáte oprávnenie na vkladanie/odstraňovanie záložiek", + "bookmark-save": "Nemožno uložiť záložku", + "bookmarks-empty": "Záložky nemôžu byť prázdne", + "library-doesnt-exist": "Knižnica neexistuje", + "invalid-path": "Neplatné umiestnenie", + "generic-send-to": "Pri odosielaní súboru(-ov) do vášho zariadenia sa vyskytla chyba", + "no-image-for-page": "Žiadny taký obrázok pre stránku {0}. Pokúste sa ju obnoviť, aby ste ju mohli nanovo uložiť.", + "delete-library-while-scan": "Nemôžete odstrániť knižnicu počas prebiehajúceho skenovania. Prosím, vyčkajte na dokončenie skenovania alebo reštartujte Kavitu a skúste ju opäť odstrániť", + "invalid-username": "Neplatné používateľské meno", + "account-email-invalid": "E-mail uvedený v údajoch administrátora nie je platným e-mailom. Nie je možné zaslať testovací e-mail.", + "admin-already-exists": "Administrátor už existuje", + "invalid-filename": "Neplatný názov súboru", + "file-doesnt-exist": "Súbor neexistuje", + "invalid-email": "E-mail v záznamoch pre používateľov nie platný e-mail. Odkazy sú uvedené v záznamoch udalostí.", + "file-missing": "Súbor nebol nájdený v knihe", + "error-import-stack": "Pri importovaní MAL balíka sa vyskytla chyba", + "person-name-required": "Meno osoby je povinné a nesmie byť prázdne", + "person-name-unique": "Meno osoby musí byť jedinečné", + "person-image-doesnt-exist": "Osoba neexistuje v databáze CoversDB", + "generic-device-create": "Pri vytváraní zariadenia sa vyskytla chyba", + "series-doesnt-exist": "Séria neexistuje", + "volume-doesnt-exist": "Zväzok neexistuje", + "library-name-exists": "Názov knižnice už existuje. Prosím, vyberte si pre daný server jedinečný názov.", + "cache-file-find": "Nepodarilo sa nájsť obrázok vo vyrovnávacej pamäti. Znova načítajte a skúste to znova.", + "name-required": "Názov nemôže byť prázdny", + "valid-number": "Musí to byť platné číslo strany", + "duplicate-bookmark": "Duplicitný záznam záložky už existuje", + "reading-list-permission": "Nemáte povolenia na tento zoznam na čítanie alebo zoznam neexistuje", + "reading-list-position": "Nepodarilo sa aktualizovať pozíciu", + "reading-list-updated": "Aktualizované", + "reading-list-item-delete": "Položku(y) sa nepodarilo odstrániť", + "reading-list-deleted": "Zoznam na čítanie bol odstránený", + "generic-reading-list-delete": "Pri odstraňovaní zoznamu na čítanie sa vyskytol problém", + "generic-reading-list-update": "Pri aktualizácii zoznamu na čítanie sa vyskytol problém", + "generic-reading-list-create": "Pri vytváraní zoznamu na čítanie sa vyskytol problém", + "reading-list-doesnt-exist": "Zoznam na čítanie neexistuje", + "series-restricted": "Používateľ nemá prístup k tejto sérii", + "generic-scrobble-hold": "Pri pauznutí funkcie sa vyskytla chyba", + "libraries-restricted": "Používateľ nemá prístup k žiadnym knižniciam", + "no-series": "Nepodarilo sa získať sériu pre knižnicu", + "no-series-collection": "Nepodarilo sa získať sériu pre kolekciu", + "generic-series-delete": "Pri odstraňovaní série sa vyskytol problém", + "generic-series-update": "Pri aktualizácii série sa vyskytla chyba", + "series-updated": "Úspešne aktualizované", + "update-metadata-fail": "Nepodarilo sa aktualizovať metadáta", + "age-restriction-not-applicable": "Bez obmedzenia", + "generic-relationship": "Pri aktualizácii vzťahov sa vyskytol problém", + "job-already-running": "Úloha už beží", + "encode-as-warning": "Nedá sa konvertovať do formátu PNG. Pre obaly použite možnosť Obnoviť obaly. Záložky a favicony sa nedajú spätne zakódovať.", + "ip-address-invalid": "IP adresa „{0}“ je neplatná", + "bookmark-dir-permissions": "Adresár záložiek nemá správne povolenia pre použitie v aplikácii Kavita", + "total-backups": "Celkový počet záloh musí byť medzi 1 a 30", + "total-logs": "Celkový počet protokolov musí byť medzi 1 a 30", + "stats-permission-denied": "Nemáte oprávnenie zobraziť si štatistiky iného používateľa", + "url-not-valid": "URL nevracia platný obrázok alebo vyžaduje autorizáciu", + "url-required": "Na použitie musíte zadať URL adresu", + "generic-cover-series-save": "Obrázok obálky sa nepodarilo uložiť do série", + "generic-cover-collection-save": "Obrázok obálky sa nepodarilo uložiť do kolekcie", + "generic-cover-reading-list-save": "Obrázok obálky sa nepodarilo uložiť do zoznamu na čítanie", + "generic-cover-chapter-save": "Obrázok obálky sa nepodarilo uložiť do kapitoly", + "generic-cover-library-save": "Obrázok obálky sa nepodarilo uložiť do knižnice", + "generic-cover-person-save": "Obrázok obálky sa nepodarilo uložiť k tejto osobe", + "generic-cover-volume-save": "Obrázok obálky sa nepodarilo uložiť do zväzku", + "access-denied": "Nemáte prístup", + "reset-chapter-lock": "Nepodarilo sa resetovať zámok obalu pre kapitolu", + "generic-user-delete": "Používateľa sa nepodarilo odstrániť", + "generic-user-pref": "Pri ukladaní predvolieb sa vyskytol problém", + "opds-disabled": "OPDS nie je na tomto serveri povolený", + "on-deck": "Pokračovať v čítaní", + "browse-on-deck": "Prehliadať pokračovanie v čítaní", + "recently-added": "Nedávno pridané", + "want-to-read": "Chcem čítať", + "browse-want-to-read": "Prehliadať Chcem si prečítať", + "browse-recently-added": "Prehliadať nedávno pridané", + "reading-lists": "Zoznamy na čítanie", + "browse-reading-lists": "Prehliadať podľa zoznamov na čítanie", + "libraries": "Všetky knižnice", + "browse-libraries": "Prehliadať podľa knižníc", + "collections": "Všetky kolekcie", + "browse-collections": "Prehliadať podľa kolekcií", + "more-in-genre": "Viac v žánri {0}", + "browse-more-in-genre": "Prezrite si viac v {0}", + "recently-updated": "Nedávno aktualizované", + "browse-recently-updated": "Prehliadať nedávno aktualizované", + "smart-filters": "Inteligentné filtre", + "external-sources": "Externé zdroje", + "browse-external-sources": "Prehliadať externé zdroje", + "browse-smart-filters": "Prehliadať podľa inteligentných filtrov", + "reading-list-restricted": "Zoznam na čítanie neexistuje alebo k nemu nemáte prístup", + "query-required": "Musíte zadať parameter dopytu", + "search": "Hľadať", + "search-description": "Vyhľadávanie sérií, zbierok alebo zoznamov na čítanie", + "favicon-doesnt-exist": "Favicon neexistuje", + "smart-filter-doesnt-exist": "Inteligentný filter neexistuje", + "smart-filter-already-in-use": "Existuje existujúci stream s týmto inteligentným filtrom", + "dashboard-stream-doesnt-exist": "Stream dashboardu neexistuje", + "sidenav-stream-doesnt-exist": "SideNav Stream neexistuje", + "external-source-already-exists": "Externý zdroj už existuje", + "external-source-required": "Vyžaduje sa kľúč API a Host", + "external-source-doesnt-exist": "Externý zdroj neexistuje", + "external-source-already-in-use": "S týmto externým zdrojom existuje stream", + "sidenav-stream-only-delete-smart-filter": "Z bočného panela SideNav je možné odstrániť iba streamy inteligentných filtrov", + "dashboard-stream-only-delete-smart-filter": "Z ovládacieho panela je možné odstrániť iba streamy inteligentných filtrov", + "smart-filter-name-required": "Názov inteligentného filtra je povinný", + "smart-filter-system-name": "Nemôžete použiť názov streamu poskytnutého systémom", + "not-authenticated": "Používateľ nie je overený", + "unable-to-register-k+": "Licenciu sa nepodarilo zaregistrovať z dôvodu chyby. Kontaktujte podporu Kavita+", + "unable-to-reset-k+": "Licenciu Kavita+ sa nepodarilo resetovať z dôvodu chyby. Kontaktujte podporu Kavita+", + "anilist-cred-expired": "Prihlasovacie údaje AniList vypršali alebo chýbajú", + "scrobble-bad-payload": "Nesprávne údaje od poskytovateľa Scrobblovania", + "theme-doesnt-exist": "Súbor témy chýba alebo je neplatný", + "bad-copy-files-for-download": "Súbory sa nepodarilo skopírovať do dočasného adresára na stiahnutie archívu.", + "generic-create-temp-archive": "Pri vytváraní dočasného archívu sa vyskytla chyba", + "epub-malformed": "Súbor je nesprávne naformátovaný! Nedá sa prečítať.", + "epub-html-missing": "Zodpovedajúci súbor HTML pre túto stránku sa nenašiel", + "collection-tag-title-required": "Názov kolekcie nemôže byť prázdny", + "reading-list-title-required": "Názov zoznamu na čítanie nemôže byť prázdny", + "collection-tag-duplicate": "Kolekcia s týmto názvom už existuje", + "device-duplicate": "Zariadenie s týmto názvom už existuje", + "device-not-created": "Toto zariadenie ešte neexistuje. Najprv ho vytvorte", + "send-to-permission": "Nie je možné odoslať súbory iné ako EPUB alebo PDF na zariadenia, pretože nie sú podporované na Kindle", + "progress-must-exist": "Pokrok musí byť u používateľa k dispozícii", + "reading-list-name-exists": "Zoznam na prečítanie s týmto menom už existuje", + "user-no-access-library-from-series": "Používateľ nemá prístup do knižnice, do ktorej táto séria patrí", + "series-restricted-age-restriction": "Používateľ si nemôže pozrieť túto sériu z dôvodu vekového obmedzenia", + "kavitaplus-restricted": "Toto je obmedzené iba na Kavita+", + "aliases-have-overlap": "Jeden alebo viacero aliasov sa prekrýva s inými osobami, nie je možné ich aktualizovať", + "volume-num": "Zväzok {0}", + "book-num": "Kniha {0}", + "issue-num": "Problém {0}{1}", + "chapter-num": "Kapitola {0}", + "check-updates": "Skontrolovať aktualizácie", + "license-check": "Kontrola licencie", + "process-scrobbling-events": "Udalosti procesu scrobblovania", + "report-stats": "Štatistiky hlásení", + "check-scrobbling-tokens": "Skontrolujte Tokeny Scrobblingu", + "cleanup": "Čistenie", + "process-processed-scrobbling-events": "Spracovať udalosti scrobblovania", + "remove-from-want-to-read": "Upratanie listu Chcem si prečítať", + "scan-libraries": "Skenovanie knižníc", + "kavita+-data-refresh": "Obnovenie údajov Kavita+", + "backup": "Záloha", + "update-yearly-stats": "Aktualizovať ročné štatistiky", + "generated-reading-profile-name": "Vygenerované z {0}" +} diff --git a/API/I18N/sl.json b/API/I18N/sl.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/API/I18N/sl.json @@ -0,0 +1 @@ +{} diff --git a/API/I18N/sv.json b/API/I18N/sv.json index 74b8d0353..64004a7a8 100644 --- a/API/I18N/sv.json +++ b/API/I18N/sv.json @@ -110,7 +110,7 @@ "search-description": "Sök efter Serier, Samlingar, eller Läslistor", "favicon-doesnt-exist": "Favikon saknas", "smart-filter-doesnt-exist": "Smart Filter saknas", - "smart-filter-already-in-use": "Det finns redan en stream med detta Smart Filter", + "smart-filter-already-in-use": "Det finns redan en ström med detta Smarta Filter", "external-source-already-exists": "Extern Källa finns redan", "external-source-required": "ApiKey och Host krävs", "external-source-doesnt-exist": "Extern Källa saknas", @@ -118,7 +118,7 @@ "not-authenticated": "Användaren är inte autentiserad", "unable-to-reset-k+": "Det gick inte att återställa Kavita+-licensen på grund av ett fel. Kontakta Kavita+ Support", "anilist-cred-expired": "AniList Autentiseringsuppgifter har gått ut eller är inte angivna", - "scrobble-bad-payload": "Felaktig payload från Scrobble Provider", + "scrobble-bad-payload": "Felaktig payload från Scrobble utgivare", "theme-doesnt-exist": "Temafil saknas eller är ogiltig", "generic-create-temp-archive": "Det uppstod ett problem vid skapandet av tillfällig katalog", "epub-malformed": "Filen är felaktigt utformad! Kan inte läsa.", @@ -178,7 +178,7 @@ "browse-collections": "Bläddra efter Samlingar", "more-in-genre": "Mer i Genre {0}", "browse-more-in-genre": "Bläddra mer i {0}", - "unable-to-register-k+": "Kan inte registrera licens på grund av fel. Kontakta Kavita+ Support", + "unable-to-register-k+": "Kan inte registrera licens på grund av ett fel. Kontakta Kavita+ Support", "bad-copy-files-for-download": "Kan inte kopiera filer till temp-katalogen för arkivnedladdning.", "epub-html-missing": "Kunde inte hitta lämplig html för den sidan", "device-not-created": "Denna enhet finns inte än. Vänligen skapa den först", @@ -194,12 +194,18 @@ "cleanup": "Städning", "on-deck": "Fortsätt Läsa", "browse-on-deck": "Bläddra i Fortsätt Läsa", - "dashboard-stream-doesnt-exist": "Dashboard Stream saknas", - "sidenav-stream-doesnt-exist": "SideNav Stream saknas", + "dashboard-stream-doesnt-exist": "Dashboard Ström saknas", + "sidenav-stream-doesnt-exist": "SideNav Ström saknas", "person-doesnt-exist": "Personen existerar inte", "person-name-required": "Personnamn är obligatorisk", "person-name-unique": "Personnamn måste vara unikt", "person-image-doesnt-exist": "Personen existerar inte i CoversDB", "generic-cover-person-save": "Kan inte spara omslagsbilden till Personen", - "generic-cover-volume-save": "Kan inte spara omslagsbilden till Volymen" + "generic-cover-volume-save": "Kan inte spara omslagsbilden till Volymen", + "email-taken": "E-postadressen används redan", + "kavitaplus-restricted": "Detta är enbart tillgängligt med Kavita+", + "dashboard-stream-only-delete-smart-filter": "Bara smarta-filter-strömmar kan tas bort från panelen", + "smart-filter-name-required": "Smart Filter namn krävs", + "sidenav-stream-only-delete-smart-filter": "Bara smarta-filter-strömmar kan tas bort från sidomenyn", + "smart-filter-system-name": "Du kan inte använda namnet från en ström som systemet tillhandahåller" } diff --git a/API/I18N/ta.json b/API/I18N/ta.json new file mode 100644 index 000000000..cc20ab40f --- /dev/null +++ b/API/I18N/ta.json @@ -0,0 +1,213 @@ +{ + "generic-invite-user": "பயனரை அழைக்கும் சிக்கல் இருந்தது. பதிவுகளை சரிபார்க்கவும்.", + "user-already-invited": "பயனர் ஏற்கனவே இந்த மின்னஞ்சலின் கீழ் அழைக்கப்படுகிறார், மேலும் அழைப்பை இன்னும் ஏற்கவில்லை.", + "invalid-email-confirmation": "தவறான மின்னஞ்சல் உறுதிப்படுத்தல்", + "generic-user-email-update": "பயனருக்கான மின்னஞ்சலைப் புதுப்பிக்க முடியவில்லை. பதிவுகளை சரிபார்க்கவும்.", + "generic-password-update": "புதிய கடவுச்சொல்லை உறுதிப்படுத்தும்போது எதிர்பாராத பிழை ஏற்பட்டது", + "password-updated": "கடவுச்சொல் புதுப்பிக்கப்பட்டது", + "forgot-password-generic": "எங்கள் தரவுத்தளத்தில் இருந்தால் மின்னஞ்சல் அனுப்பப்படும்", + "bad-copy-files-for-download": "கோப்புகளை தற்காலிக அடைவு காப்பக பதிவிறக்கத்திற்கு நகலெடுக்க முடியவில்லை.", + "generic-create-temp-archive": "தற்காலிக காப்பகத்தை உருவாக்கும் சிக்கல் இருந்தது", + "epub-malformed": "கோப்பு தவறாக உள்ளது! படிக்க முடியாது.", + "epub-html-missing": "அந்தப் பக்கத்திற்கு பொருத்தமான உஉகுமொ ஐக் கண்டுபிடிக்க முடியவில்லை", + "collection-tag-title-required": "சேகரிப்பு தலைப்பு காலியாக இருக்க முடியாது", + "collection-tag-duplicate": "இந்த பெயருடன் ஒரு தொகுப்பு ஏற்கனவே உள்ளது", + "device-duplicate": "இந்த பெயரைக் கொண்ட ஒரு சாதனம் ஏற்கனவே உள்ளது", + "not-accessible-password": "உங்கள் சேவையகம் அணுக முடியாது. உங்கள் கடவுச்சொல்லை மீட்டமைப்பதற்கான இணைப்பு பதிவுகளில் உள்ளது", + "device-not-created": "இந்த சாதனம் இன்னும் இல்லை. முதலில் உருவாக்கவும்", + "send-to-permission": "கின்டலில் ஆதரிக்கப்படாத சாதனங்களுக்கு எபப் அல்லாத அல்லது பி.டி.எஃப் அனுப்ப முடியாது", + "progress-must-exist": "பயனரில் முன்னேற்றம் இருக்க வேண்டும்", + "reading-list-name-exists": "இந்த பெயரின் வாசிப்பு பட்டியல் ஏற்கனவே உள்ளது", + "user-no-access-library-from-series": "பயனருக்கு நூலகத்திற்கு அணுகல் இல்லை இந்த தொடர் சொந்தமானது", + "series-restricted-age-restriction": "அகவை கட்டுப்பாடுகள் காரணமாக இந்தத் தொடரைப் பார்க்க பயனருக்கு இசைவு இல்லை", + "volume-num": "தொகுதி {0}", + "book-num": "நூல் {0}", + "issue-num": "வெளியீடு {0} {1}", + "chapter-num": "அத்தியாயம் {0}", + "check-updates": "புதுப்பிப்புகளை சரிபார்க்கவும்", + "license-check": "உரிம சோதனை", + "process-scrobbling-events": "செயலாக்க நிகழ்வுகளை செயலாக்கவும்", + "report-stats": "புள்ளிவிவரங்களைப் புகாரளிக்கவும்", + "check-scrobbling-tokens": "ச்க்ரோப்ளிங் டோக்கன்களை சரிபார்க்கவும்", + "cleanup": "தூய்மைப்படுத்துதல்", + "process-processed-scrobbling-events": "செயல்முறை செயலாக்கப்பட்ட ச்க்ரோப்லிங் நிகழ்வுகள்", + "remove-from-want-to-read": "தூய்மைப்படுத்தலைப் படிக்க விரும்புகிறேன்", + "scan-libraries": "நூலகங்களை ச்கேன் செய்யுங்கள்", + "kavita+-data-refresh": "கவிதா+ தரவு புதுப்பிப்பு", + "backup": "காப்புப்பிரதி", + "update-yearly-stats": "ஆண்டு புள்ளிவிவரங்களைப் புதுப்பிக்கவும்", + "invalid-email": "பயனருக்கான கோப்பில் உள்ள மின்னஞ்சல் சரியான மின்னஞ்சல் அல்ல. எந்த இணைப்புகளுக்கான பதிவுகளையும் காண்க.", + "not-accessible": "உங்கள் சேவையகம் வெளிப்புறமாக அணுக முடியாது", + "email-sent": "மின்னஞ்சல் அனுப்பப்பட்டது", + "user-migration-needed": "இந்த பயனர் இடம்பெயர வேண்டும். இடம்பெயர்வு ஓட்டத்தைத் தூண்டுவதற்கு அவை வெளியேறி உள்நுழைய வேண்டும்", + "generic-invite-email": "அழைப்பு மின்னஞ்சல் மீண்டும் ஒரு சிக்கல் இருந்தது", + "admin-already-exists": "நிர்வாகி ஏற்கனவே உள்ளது", + "invalid-username": "தவறான பயனர்பெயர்", + "critical-email-migration": "மின்னஞ்சல் இடம்பெயர்வு போது ஒரு சிக்கல் இருந்தது. தொடர்பு உதவி", + "email-not-enabled": "இந்த சேவையகத்தில் மின்னஞ்சல் இயக்கப்படவில்லை. இந்த செயலை நீங்கள் செய்ய முடியாது.", + "must-be-defined": "{0} வரையறுக்கப்பட வேண்டும்", + "generic-favicon": "டொமைனுக்கு ஃபேவிகானைப் பெறுவதில் சிக்கல் இருந்தது", + "invalid-filename": "தவறான கோப்பு பெயர்", + "file-doesnt-exist": "கோப்பு இல்லை", + "library-name-exists": "நூலக பெயர் ஏற்கனவே உள்ளது. சேவையகத்திற்கு ஒரு தனிப்பட்ட பெயரைத் தேர்வுசெய்க.", + "generic-library": "ஒரு முக்கியமான சிக்கல் இருந்தது. மீண்டும் முயற்சிக்கவும்.", + "no-library-access": "பயனருக்கு இந்த நூலகத்திற்கு அணுகல் இல்லை", + "library-doesnt-exist": "நூலகம் இல்லை", + "invalid-path": "தவறான பாதை", + "no-series-collection": "சேகரிப்புக்கான தொடர்களைப் பெற முடியவில்லை", + "generic-series-delete": "தொடரை நீக்குவதில் சிக்கல் இருந்தது", + "generic-series-update": "தொடரைப் புதுப்பிப்பதில் பிழை ஏற்பட்டது", + "series-updated": "வெற்றிகரமாக புதுப்பிக்கப்பட்டது", + "update-metadata-fail": "மெட்டாடேட்டாவை புதுப்பிக்க முடியவில்லை", + "age-restriction-not-applicable": "கட்டுப்பாடு இல்லை", + "generic-relationship": "உறவுகளைப் புதுப்பிப்பதில் சிக்கல் இருந்தது", + "job-already-running": "ஏற்கனவே இயங்கும் வேலை", + "browse-reading-lists": "பட்டியல்களைப் படிப்பதன் மூலம் உலாவுக", + "libraries": "அனைத்து நூலகங்களும்", + "browse-libraries": "நூலகங்களால் உலாவுக", + "collections": "அனைத்து சேகரிப்புகளும்", + "browse-collections": "வசூல் மூலம் உலாவுக", + "more-in-genre": "{0} வகைகளில் மேலும்", + "browse-more-in-genre": "{0} இல் மேலும் உலாவுக", + "recently-updated": "அண்மைக் காலத்தில் புதுப்பிக்கப்பட்டது", + "browse-recently-updated": "உலாவு அண்மைக் காலத்தில் புதுப்பிக்கப்பட்டது", + "smart-filters": "அறிவுள்ள வடிப்பான்கள்", + "external-sources": "வெளிப்புற ஆதாரங்கள்", + "browse-external-sources": "வெளிப்புற ஆதாரங்களை உலாவுக", + "browse-smart-filters": "அறிவுள்ள வடிப்பான்களால் உலாவுக", + "reading-list-restricted": "வாசிப்பு பட்டியல் இல்லை அல்லது உங்களுக்கு அணுகல் இல்லை", + "query-required": "நீங்கள் ஒரு வினவல் அளவுருவை அனுப்ப வேண்டும்", + "search-description": "தொடர், தொகுப்புகள் அல்லது வாசிப்பு பட்டியல்களைத் தேடுங்கள்", + "favicon-doesnt-exist": "ஃபாவிகான் இல்லை", + "smart-filter-doesnt-exist": "அறிவுள்ள வடிகட்டி இல்லை", + "smart-filter-already-in-use": "இந்த அறிவுள்ள வடிகட்டியுடன் ஏற்கனவே ச்ட்ரீம் உள்ளது", + "dashboard-stream-doesnt-exist": "டாச்போர்டு ச்ட்ரீம் இல்லை", + "sidenav-stream-doesnt-exist": "சிடெனவ் ச்ட்ரீம் இல்லை", + "external-source-already-exists": "வெளிப்புற மூலமானது ஏற்கனவே உள்ளது", + "external-source-required": "அப்பிகி மற்றும் புரவலன் தேவை", + "external-source-doesnt-exist": "வெளிப்புற மூல இல்லை", + "external-source-already-in-use": "இந்த வெளிப்புற மூலத்துடன் ஏற்கனவே ச்ட்ரீம் உள்ளது", + "not-authenticated": "பயனர் அங்கீகரிக்கப்படவில்லை", + "unable-to-register-k+": "பிழை காரணமாக உரிமத்தை பதிவு செய்ய முடியவில்லை. கவிதா+ ஆதரவை அணுகவும்", + "unable-to-reset-k+": "Unable பெறுநர் மீட்டமை Kavita+ உரிமம் due பெறுநர் error. கவிதா+ ஆதரவை அணுகவும்", + "anilist-cred-expired": "அனிலிச்ட் நற்சான்றிதழ்கள் காலாவதியானன அல்லது அமைக்கப்படவில்லை", + "scrobble-bad-payload": "ச்க்ரோபல் வழங்குநரிடமிருந்து மோசமான பேலோட்", + "theme-doesnt-exist": "கருப்பொருள் கோப்பு காணவில்லை அல்லது தவறானது", + "search": "தேடல்", + "age-restriction-update": "அகவை கட்டுப்பாட்டை புதுப்பிப்பதில் பிழை ஏற்பட்டது", + "bookmark-doesnt-exist": "புக்மார்க்கு இல்லை", + "user-doesnt-exist": "பயனர் இல்லை", + "reading-list-item-delete": "உருப்படி (களை) நீக்க முடியவில்லை", + "libraries-restricted": "பயனருக்கு எந்த நூலகங்களுக்கும் அணுகல் இல்லை", + "reading-list-title-required": "பட்டியல் தலைப்பு காலியாக இருக்க முடியாது", + "confirm-email": "முதலில் உங்கள் மின்னஞ்சலை உறுதிப்படுத்த வேண்டும்", + "locked-out": "பல அங்கீகார முயற்சிகளிலிருந்து நீங்கள் பூட்டப்பட்டுள்ளீர்கள். தயவுசெய்து 10 நிமிடங்கள் காத்திருக்கவும்.", + "disabled-account": "உங்கள் கணக்கு முடக்கப்பட்டுள்ளது. சேவையக நிர்வாகியைத் தொடர்பு கொள்ளுங்கள்.", + "register-user": "பயனரை பதிவு செய்யும் போது ஏதோ தவறு ஏற்பட்டது", + "validate-email": "உங்கள் மின்னஞ்சலை உறுதிப்படுத்தும் சிக்கல் இருந்தது: {0}", + "confirm-token-gen": "உறுதிப்படுத்தல் கிள்ளாக்கை உருவாக்கும் சிக்கல் இருந்தது", + "denied": "அனுமதிக்கப்படவில்லை", + "permission-denied": "இந்த நடவடிக்கைக்கு நீங்கள் அனுமதிக்கப்படவில்லை", + "password-required": "நீங்கள் ஒரு நிர்வாகி இல்லையென்றால் உங்கள் கணக்கை மாற்ற உங்கள் ஏற்கனவே உள்ள கடவுச்சொல்லை உள்ளிட வேண்டும்", + "invalid-password": "தவறான கடவுச்சொல்", + "invalid-token": "தவறான கிள்ளாக்கு", + "unable-to-reset-key": "விசையை மீட்டமைக்க முடியாமல் ஏதோ தவறு நடந்தது", + "invalid-payload": "தவறான பேலோட்", + "nothing-to-do": "செய்ய எதுவும் இல்லை", + "share-multiple-emails": "பல கணக்குகளில் மின்னஞ்சல்களைப் பகிர முடியாது", + "generate-token": "உறுதிப்படுத்தல் மின்னஞ்சல் கிள்ளாக்கை உருவாக்கும் சிக்கல் இருந்தது. பதிவுகள் பார்க்கவும்", + "no-user": "பயனர் இல்லை", + "username-taken": "ஏற்கனவே எடுக்கப்பட்ட பயனர்பெயர்", + "email-taken": "ஏற்கனவே பயன்பாட்டில் உள்ள மின்னஞ்சல்", + "user-already-confirmed": "பயனர் ஏற்கனவே உறுதிப்படுத்தப்பட்டுள்ளது", + "generic-user-update": "பயனரைப் புதுப்பிக்கும்போது விதிவிலக்கு இருந்தது", + "manual-setup-fail": "கையேடு அமைப்பை முடிக்க முடியவில்லை. தயவுசெய்து அழைப்பை ரத்து செய்து மீண்டும் உருவாக்கவும்", + "user-already-registered": "பயனர் ஏற்கனவே {0 அச் என பதிவு செய்யப்பட்டுள்ளார்", + "account-email-invalid": "நிர்வாகக் கணக்கிற்கான கோப்பில் உள்ள மின்னஞ்சல் சரியான மின்னஞ்சல் அல்ல. சோதனை மின்னஞ்சலை அனுப்ப முடியாது.", + "email-settings-invalid": "மின்னஞ்சல் அமைப்புகள் காணவில்லை. அனைத்து மின்னஞ்சல் அமைப்புகளும் சேமிக்கப்படுவதை உறுதிசெய்க.", + "chapter-doesnt-exist": "அத்தியாயம் இல்லை", + "file-missing": "கோப்பு புத்தகத்தில் காணப்படவில்லை", + "collection-updated": "சேகரிப்பு வெற்றிகரமாக புதுப்பிக்கப்பட்டது", + "collection-deleted": "சேகரிப்பு நீக்கப்பட்டது", + "generic-error": "ஏதோ தவறு நடந்தது, தயவுசெய்து மீண்டும் முயற்சிக்கவும்", + "collection-doesnt-exist": "சேகரிப்பு இல்லை", + "collection-already-exists": "சேகரிப்பு ஏற்கனவே உள்ளது", + "error-import-stack": "மால் ச்டேக்கை இறக்குமதி செய்வதில் சிக்கல் இருந்தது", + "person-doesnt-exist": "நபர் இல்லை", + "person-name-required": "நபரின் பெயர் தேவை மற்றும் பூச்யமாக இருக்கக்கூடாது", + "person-name-unique": "நபரின் பெயர் தனித்துவமாக இருக்க வேண்டும்", + "person-image-doesnt-exist": "கவர்ச்டிபியில் நபர் இல்லை", + "device-doesnt-exist": "சாதனம் இல்லை", + "generic-device-create": "சாதனத்தை உருவாக்கும் போது பிழை ஏற்பட்டது", + "generic-device-update": "சாதனத்தைப் புதுப்பிக்கும்போது பிழை ஏற்பட்டது", + "generic-device-delete": "சாதனத்தை நீக்கும்போது பிழை ஏற்பட்டது", + "greater-0": "{0} 0 ஐ விட அதிகமாக இருக்க வேண்டும்", + "send-to-kavita-email": "மின்னஞ்சல் அமைவு இல்லாமல் சாதனத்திற்கு அனுப்பு பயன்படுத்த முடியாது", + "send-to-unallowed": "உங்களுடையது இல்லாத சாதனத்திற்கு நீங்கள் அனுப்ப முடியாது", + "send-to-size-limit": "நீங்கள் அனுப்ப முயற்சிக்கும் கோப்பு (கள்) உங்கள் மின்னஞ்சல் வழங்குநருக்கு மிகப் பெரியது", + "send-to-device-status": "உங்கள் சாதனத்திற்கு கோப்புகளை மாற்றுகிறது", + "generic-send-to": "சாதனத்திற்கு கோப்பு (களை) அனுப்புவதில் பிழை ஏற்பட்டது", + "series-doesnt-exist": "தொடர் இல்லை", + "volume-doesnt-exist": "தொகுதி இல்லை", + "bookmarks-empty": "புக்மார்க்குகள் காலியாக இருக்க முடியாது", + "no-cover-image": "கவர் படம் இல்லை", + "delete-library-while-scan": "ச்கேன் நடந்து கொண்டிருக்கும்போது நீங்கள் ஒரு நூலகத்தை நீக்க முடியாது. கவிதாவை ச்கேன் முடிக்க அல்லது மறுதொடக்கம் செய்ய காத்திருங்கள்", + "generic-library-update": "நூலகத்தைப் புதுப்பிப்பதில் ஒரு முக்கியமான சிக்கல் இருந்தது.", + "pdf-doesnt-exist": "பி.டி.எஃப் எப்போது இருக்க வேண்டும்", + "invalid-access": "தவறான அணுகல்", + "no-image-for-page": "பக்கத்திற்கு அத்தகைய படம் இல்லை {0}. மறு கேச் அனுமதிக்க புத்துணர்ச்சியை முயற்சிக்கவும்.", + "perform-scan": "தயவுசெய்து இந்த தொடர் அல்லது நூலகத்தில் ச்கேன் செய்து மீண்டும் முயற்சிக்கவும்", + "generic-read-progress": "முன்னேற்றத்தை மிச்சப்படுத்தும் சிக்கல் இருந்தது", + "generic-clear-bookmarks": "புக்மார்க்குகளை அழிக்க முடியவில்லை", + "bookmark-permission": "புக்மார்க்கு/புத்தகமார்க்குக்கு உங்களுக்கு இசைவு இல்லை", + "bookmark-save": "புத்தகக்குறியை சேமிக்க முடியவில்லை", + "cache-file-find": "தற்காலிக சேமிப்பு படத்தைக் கண்டுபிடிக்க முடியவில்லை. மீண்டும் ஏற்றவும் மீண்டும் முயற்சிக்கவும்.", + "name-required": "பெயர் காலியாக இருக்க முடியாது", + "valid-number": "செல்லுபடியாகும் பக்க எண்ணாக இருக்க வேண்டும்", + "duplicate-bookmark": "நகல் புத்தகக்குறி நுழைவு ஏற்கனவே உள்ளது", + "reading-list-permission": "இந்த வாசிப்பு பட்டியலில் உங்களிடம் இசைவு இல்லை அல்லது பட்டியல் இல்லை", + "reading-list-position": "நிலையை புதுப்பிக்க முடியவில்லை", + "reading-list-updated": "புதுப்பிக்கப்பட்டது", + "reading-list-deleted": "வாசிப்பு பட்டியல் நீக்கப்பட்டது", + "generic-reading-list-delete": "வாசிப்பு பட்டியலை நீக்குவதில் சிக்கல் இருந்தது", + "generic-reading-list-update": "வாசிப்பு பட்டியலைப் புதுப்பிப்பதில் சிக்கல் இருந்தது", + "generic-reading-list-create": "வாசிப்பு பட்டியலை உருவாக்கும் சிக்கல் இருந்தது", + "reading-list-doesnt-exist": "வாசிப்பு பட்டியல் இல்லை", + "series-restricted": "பயனருக்கு இந்த தொடருக்கான அணுகல் இல்லை", + "generic-scrobble-hold": "பிடியைச் சேர்க்கும்போது பிழை ஏற்பட்டது", + "no-series": "நூலகத்திற்கான தொடர்களைப் பெற முடியவில்லை", + "encode-as-warning": "நீங்கள் பி.என்.சி.க்கு மாற்ற முடியாது. அட்டைகளுக்கு, புதுப்பிப்பு அட்டைகளைப் பயன்படுத்தவும். புக்மார்க்குகள் மற்றும் ஃபாவிகான்களை மீண்டும் குறியாக்கம் செய்ய முடியாது.", + "ip-address-invalid": "ஐபி முகவரி '{0}' தவறானது", + "bookmark-dir-permissions": "கவிதாவைப் பயன்படுத்த புக்மார்க்கு கோப்பகத்திற்கு சரியான அனுமதிகள் இல்லை", + "total-backups": "மொத்த காப்புப்பிரதிகள் 1 முதல் 30 வரை இருக்க வேண்டும்", + "total-logs": "மொத்த பதிவுகள் 1 முதல் 30 வரை இருக்க வேண்டும்", + "stats-permission-denied": "மற்றொரு பயனரின் புள்ளிவிவரங்களைக் காண உங்களுக்கு ஏற்பு இல்லை", + "url-not-valid": "முகவரி சரியான படத்தை திருப்பித் தராது அல்லது ஏற்பு தேவைப்படுகிறது", + "url-required": "பயன்படுத்த நீங்கள் ஒரு முகவரி ஐ அனுப்ப வேண்டும்", + "generic-cover-series-save": "கவர் படத்தை தொடருக்கு சேமிக்க முடியவில்லை", + "generic-cover-collection-save": "கவர் படத்தை சேகரிப்புக்கு சேமிக்க முடியவில்லை", + "generic-cover-reading-list-save": "கவர் படத்தை வாசிப்பு பட்டியலுக்கு சேமிக்க முடியவில்லை", + "generic-cover-chapter-save": "கவர் படத்தை அத்தியாயத்தில் சேமிக்க முடியவில்லை", + "generic-cover-library-save": "கவர் படத்தை நூலகத்தில் சேமிக்க முடியவில்லை", + "generic-cover-person-save": "கவர் படத்தை நபருக்கு சேமிக்க முடியவில்லை", + "generic-cover-volume-save": "கவர் படத்தை தொகுதிக்கு சேமிக்க முடியவில்லை", + "access-denied": "உங்களுக்கு அணுகல் இல்லை", + "reset-chapter-lock": "அத்தியாயத்திற்கான கவர் பூட்டை மீட்டமைக்க முடியவில்லை", + "generic-user-delete": "பயனரை நீக்க முடியவில்லை", + "generic-user-pref": "விருப்பங்களை சேமிக்கும் சிக்கல் இருந்தது", + "opds-disabled": "இந்த சேவையகத்தில் OPDS இயக்கப்படவில்லை", + "on-deck": "டெக்கில்", + "browse-on-deck": "டெக்கில் உலாவுக", + "recently-added": "அண்மைக் காலத்தில் சேர்க்கப்பட்டது", + "want-to-read": "படிக்க விரும்புகிறேன்", + "browse-want-to-read": "உலாவு படிக்க விரும்புகிறது", + "browse-recently-added": "உலாவு அண்மைக் காலத்தில் சேர்க்கப்பட்டது", + "reading-lists": "பட்டியல்களைப் படித்தல்", + "sidenav-stream-only-delete-smart-filter": "சைடனாவிலிருந்து அறிவுள்ள வடிகட்டி நீரோடைகளை மட்டுமே நீக்க முடியும்", + "dashboard-stream-only-delete-smart-filter": "டாச்போர்டில் இருந்து அறிவுள்ள வடிகட்டி ச்ட்ரீம்களை மட்டுமே நீக்க முடியும்", + "smart-filter-name-required": "அறிவுள்ள வடிகட்டி பெயர் தேவை", + "smart-filter-system-name": "வழங்கப்பட்ட ச்ட்ரீமின் பெயரை நீங்கள் பயன்படுத்த முடியாது", + "kavitaplus-restricted": "இது கவிதா+ க்கு மட்டுமே", + "aliases-have-overlap": "ஒன்று அல்லது அதற்கு மேற்பட்ட மாற்றுப்பெயர்கள் மற்றவர்களுடன் ஒன்றுடன் ஒன்று உள்ளன, புதுப்பிக்க முடியாது", + "generated-reading-profile-name": "{0 இருந்து இலிருந்து உருவாக்கப்பட்டது" +} diff --git a/API/I18N/tr.json b/API/I18N/tr.json index fb72952b1..370f13f20 100644 --- a/API/I18N/tr.json +++ b/API/I18N/tr.json @@ -3,7 +3,7 @@ "permission-denied": "Bu operasyona izniniz yok", "confirm-email": "İlk olarak E-Posta'nı onaylaman gerek", "register-user": "Kullanıcıyı kayıt ederken bir şeyler yanlış gitti", - "disabled-account": "Hesabınız devre dışı bırakıldı. Sunucu yöneticisiyle iletişime geçin.", + "disabled-account": "Hesabınız devre dışı. Sunucu yöneticisiyle iletişime geçin.", "validate-email": "E-Posta'yı doğrularken bir hata oluştu: {0}", "confirm-token-gen": "Doğrulama tokeni oluşturulurken bir sorun oluştu", "password-required": "Yönetici değilseniz, hesabınızı değiştirmek için mevcut şifrenizi girmelisiniz", diff --git a/API/I18N/uk.json b/API/I18N/uk.json index a8afe85a9..62f514a04 100644 --- a/API/I18N/uk.json +++ b/API/I18N/uk.json @@ -13,5 +13,12 @@ "nothing-to-do": "Тут нема роботи", "share-multiple-emails": "Не можна користуватися одним email з кількох облікових записів", "confirm-token-gen": "Щось пішло не так під час генерації коду підтвердження", - "invalid-password": "Неправильний пароль" + "invalid-password": "Неправильний пароль", + "generic-user-update": "Сталася помилка при спробі оновлення інформації користувача", + "generate-token": "Відбулась помилка при спробі підтвердження токену. Перегляньте логи", + "age-restriction-update": "Сталася помилка під час оновлення обмежень віку", + "no-user": "Користувача не існує", + "username-taken": "Ім'я користувача уже зайняте", + "user-already-confirmed": "Користувач уже підтверджений", + "email-taken": "Адреса електронної пошти уже зайнята" } diff --git a/API/I18N/zh_Hans.json b/API/I18N/zh_Hans.json index 01e1eaf52..14c8c902e 100644 --- a/API/I18N/zh_Hans.json +++ b/API/I18N/zh_Hans.json @@ -201,5 +201,13 @@ "person-doesnt-exist": "人员不存在", "person-name-required": "人员姓名为必填项,且不能为空", "person-name-unique": "人名必须是唯一的", - "person-image-doesnt-exist": "CoversDB 中不存在此人" + "person-image-doesnt-exist": "CoversDB 中不存在此人", + "email-taken": "电子邮件已被使用", + "kavitaplus-restricted": "仅限 Kavita+", + "dashboard-stream-only-delete-smart-filter": "只能从仪表板中删除智能筛选器流", + "smart-filter-name-required": "需要智能筛选器名称", + "smart-filter-system-name": "您不能使用系统提供的流名称", + "sidenav-stream-only-delete-smart-filter": "只能从侧边栏删除智能筛选器流", + "aliases-have-overlap": "一个或多个别名与其他人有重叠,无法更新", + "generated-reading-profile-name": "由 {0} 生成" } diff --git a/API/I18N/zh_Hant.json b/API/I18N/zh_Hant.json index 04ec9a4b2..31d4b69f6 100644 --- a/API/I18N/zh_Hant.json +++ b/API/I18N/zh_Hant.json @@ -173,7 +173,7 @@ "send-to-unallowed": "您無法傳送到不是您自己的裝置", "email-settings-invalid": "電子郵件設定缺少資訊。請確保所有電子郵件設定已保存。", "collection-already-exists": "組合已存在", - "send-to-size-limit": "您嘗試發送的文件對於您的電子郵件提供者來說太大", + "send-to-size-limit": "您嘗試傳送的文件過大,無法通過您的電子郵件服務提供商發送", "external-sources": "外部來源", "dashboard-stream-doesnt-exist": "儀表板串流不存在", "unable-to-reset-k+": "發生錯誤,無法重置 Kavita+ 授權。請聯繫 Kavita+ 支援", @@ -193,13 +193,21 @@ "update-yearly-stats": "更新年度統計", "invalid-email": "使用者檔案中的電子郵件無效。請查看日誌以獲得任何連結。", "browse-external-sources": "瀏覽外部來源", - "sidenav-stream-doesnt-exist": "側邊導覽串流不存在", + "sidenav-stream-doesnt-exist": "側邊導覽列的串流不存在", "smart-filter-already-in-use": "已存在具有此智慧篩選器的串流", "external-source-already-exists": "外部來源已存在", "generic-cover-volume-save": "無法保存封面圖片", "generic-cover-person-save": "無法保存封面圖片", "person-doesnt-exist": "此人不存在", - "person-name-required": "个人姓名是必需的,不得取消", - "person-name-unique": "个人姓名必须是独特的", - "person-image-doesnt-exist": "CoversDB 中不存在此人" + "person-name-required": "名稱為必填欄位,且不得留空", + "person-name-unique": "名稱不得重複", + "person-image-doesnt-exist": "CoversDB 中不存在此人", + "email-taken": "電子郵件已被使用", + "sidenav-stream-only-delete-smart-filter": "只有智慧篩選器可以從側邊導覽列中刪除", + "dashboard-stream-only-delete-smart-filter": "只有智慧篩選器串流可以從儀表板中刪除", + "smart-filter-name-required": "智慧篩選器名稱名稱不可為空", + "smart-filter-system-name": "您不能使用系統保留的串流名稱", + "kavitaplus-restricted": "此功能僅限 Kavita+ 使用", + "aliases-have-overlap": "一個或多個別名與其他人物重複,無法更新", + "generated-reading-profile-name": "由 {0} 生成" } diff --git a/API/Middleware/SecurityMiddleware.cs b/API/Middleware/SecurityMiddleware.cs index 61ca1c75d..67cb42d0c 100644 --- a/API/Middleware/SecurityMiddleware.cs +++ b/API/Middleware/SecurityMiddleware.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Net; using System.Text.Json; using System.Threading.Tasks; @@ -26,7 +27,7 @@ public class SecurityEventMiddleware(RequestDelegate next) } catch (KavitaUnauthenticatedUserException ex) { - var ipAddress = context.Connection.RemoteIpAddress?.ToString(); + var ipAddress = context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString(); var requestMethod = context.Request.Method; var requestPath = context.Request.Path; var userAgent = context.Request.Headers.UserAgent; diff --git a/API/Program.cs b/API/Program.cs index 544d568d1..011a7de2a 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,5 +1,5 @@ using System; -using System.Globalization; +using System.IO; using System.IO.Abstractions; using System.Linq; using System.Security.Cryptography; @@ -21,9 +21,11 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using NetVips; using Serilog; using Serilog.Events; using Serilog.Sinks.AspNetCore.SignalR.Extensions; +using Log = Serilog.Log; namespace API; #nullable enable @@ -38,9 +40,6 @@ public class Program public static async Task Main(string[] args) { - CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; - CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; - Console.OutputEncoding = System.Text.Encoding.UTF8; Log.Logger = new LoggerConfiguration() .WriteTo.Console() @@ -50,18 +49,13 @@ public class Program var directoryService = new DirectoryService(null!, new FileSystem()); - // Before anything, check if JWT has been generated properly or if user still has default - if (!Configuration.CheckIfJwtTokenSet() && - Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development) - { - Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions..."); - var rBytes = new byte[256]; - RandomNumberGenerator.Create().GetBytes(rBytes); - Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); - } - Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "http://localhost:5020" : "https://plus.kavitareader.com"; + // Check if this is the first time running and if so, rename appsettings-init.json to appsettings.json + HandleFirstRunConfiguration(); + + + // Before anything, check if JWT has been generated properly or if user still has default + EnsureJwtTokenKey(); try { @@ -75,6 +69,7 @@ public class Program { var logger = services.GetRequiredService>(); var context = services.GetRequiredService(); + var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); var isDbCreated = await context.Database.CanConnectAsync(); if (isDbCreated && pendingMigrations.Any()) @@ -132,6 +127,7 @@ public class Program await Seed.SeedDefaultStreams(unitOfWork); await Seed.SeedDefaultSideNavStreams(unitOfWork); await Seed.SeedUserApiKeys(context); + await Seed.SeedMetadataSettings(context); } catch (Exception ex) { @@ -149,6 +145,8 @@ public class Program var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); LogLevelOptions.SwitchLogLevel(settings.LoggingLevel); + InitNetVips(); + await host.RunAsync(); } catch (Exception ex) { @@ -159,6 +157,26 @@ public class Program } } + private static void EnsureJwtTokenKey() + { + if (Configuration.CheckIfJwtTokenSet() || Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) return; + + Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions..."); + var rBytes = new byte[256]; + RandomNumberGenerator.Create().GetBytes(rBytes); + Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); + } + + private static void HandleFirstRunConfiguration() + { + var firstRunConfigFilePath = Path.Join(Directory.GetCurrentDirectory(), "config/appsettings-init.json"); + if (File.Exists(firstRunConfigFilePath) && + !File.Exists(Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json"))) + { + File.Move(firstRunConfigFilePath, Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json")); + } + } + private static async Task GetMigrationDirectory(DataContext context, IDirectoryService directoryService) { string? currentVersion = null; @@ -231,4 +249,14 @@ public class Program webBuilder.UseStartup(); }); + + /// + /// Ensure NetVips does not cache + /// + /// https://github.com/kleisauke/net-vips/issues/6#issuecomment-394379299 + private static void InitNetVips() + { + Cache.MaxFiles = 0; + + } } diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 241198811..74b6709fa 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -5,8 +5,10 @@ using System.Threading.Tasks; using System.Web; using API.Constants; using API.Data; +using API.DTOs.Account; using API.Entities; using API.Errors; +using API.Extensions; using Kavita.Common; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -46,7 +48,7 @@ public class AccountService : IAccountService public async Task> ChangeUserPassword(AppUser user, string newPassword) { var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList(); - if (passwordValidationIssues.Any()) return passwordValidationIssues; + if (passwordValidationIssues.Count != 0) return passwordValidationIssues; var result = await _userManager.RemovePasswordAsync(user); if (!result.Succeeded) @@ -55,15 +57,11 @@ public class AccountService : IAccountService return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } - result = await _userManager.AddPasswordAsync(user, newPassword); - if (!result.Succeeded) - { - _logger.LogError("Could not update password"); - return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); - } + if (result.Succeeded) return []; - return new List(); + _logger.LogError("Could not update password"); + return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } public async Task> ValidatePassword(AppUser user, string password) @@ -81,15 +79,17 @@ public class AccountService : IAccountService } public async Task> ValidateUsername(string username) { - if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) + // Reverted because of https://go.microsoft.com/fwlink/?linkid=2129535 + if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName != null + && x.NormalizedUserName == username.ToUpper())) { - return new List() - { - new ApiException(400, "Username is already taken") - }; + return + [ + new(400, "Username is already taken") + ]; } - return Array.Empty(); + return []; } public async Task> ValidateEmail(string email) @@ -112,6 +112,7 @@ public class AccountService : IAccountService { if (user == null) return false; var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.BookmarkRole) || roles.Contains(PolicyConstants.AdminRole); } @@ -124,6 +125,7 @@ public class AccountService : IAccountService { if (user == null) return false; var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole); } @@ -135,9 +137,10 @@ public class AccountService : IAccountService public async Task CanChangeAgeRestriction(AppUser? user) { if (user == null) return false; + var roles = await _userManager.GetRolesAsync(user); if (roles.Contains(PolicyConstants.ReadOnlyRole)) return false; + return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); } - } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index aa0447fc2..335a5a74b 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -16,6 +16,7 @@ using Kavita.Common; using Microsoft.Extensions.Logging; using SharpCompress.Archives; using SharpCompress.Common; +using YamlDotNet.Core; namespace API.Services; @@ -354,16 +355,23 @@ public class ArchiveService : IArchiveService foreach (var path in files) { var tempPath = Path.Join(tempLocation, _directoryService.FileSystem.Path.GetFileNameWithoutExtension(_directoryService.FileSystem.FileInfo.New(path).Name)); - progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count)); - if (Tasks.Scanner.Parser.Parser.IsArchive(path)) + + // Image series need different handling + if (Tasks.Scanner.Parser.Parser.IsImage(path)) { - ExtractArchive(path, tempPath); - } - else - { - _directoryService.CopyFileToDirectory(path, tempPath); + var parentDirectory = _directoryService.FileSystem.DirectoryInfo.New(path).Parent?.Name; + tempPath = Path.Join(tempLocation, parentDirectory ?? _directoryService.FileSystem.FileInfo.New(path).Name); } + if (Tasks.Scanner.Parser.Parser.IsArchive(path)) + { + // Archives don't need to be put into a subdirectory of the same name + tempPath = _directoryService.GetParentDirectoryName(tempPath); + } + + progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count)); + + _directoryService.CopyFileToDirectory(path, tempPath); count++; } } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 740227af8..99fdd1400 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -6,12 +6,14 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Xml; using API.Data.Metadata; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services.Tasks.Scanner.Parser; +using API.Helpers; using Docnet.Core; using Docnet.Core.Converters; using Docnet.Core.Models; @@ -69,12 +71,50 @@ public class BookService : IBookService private static readonly RecyclableMemoryStreamManager StreamManager = new (); private const string CssScopeClass = ".book-content"; private const string BookApiUrl = "book-resources?file="; + private readonly PdfComicInfoExtractor _pdfComicInfoExtractor; + + /// + /// Setup the most lenient book parsing options possible as people have some really bad epubs + /// public static readonly EpubReaderOptions BookReaderOptions = new() { PackageReaderOptions = new PackageReaderOptions { IgnoreMissingToc = true, - SkipInvalidManifestItems = true + SkipInvalidManifestItems = true, + }, + Epub2NcxReaderOptions = new Epub2NcxReaderOptions + { + IgnoreMissingContentForNavigationPoints = false + }, + SpineReaderOptions = new SpineReaderOptions + { + IgnoreMissingManifestItems = false + }, + BookCoverReaderOptions = new BookCoverReaderOptions + { + Epub2MetadataIgnoreMissingManifestItem = false + } + }; + + public static readonly EpubReaderOptions LenientBookReaderOptions = new() + { + PackageReaderOptions = new PackageReaderOptions + { + IgnoreMissingToc = true, + SkipInvalidManifestItems = true, + }, + Epub2NcxReaderOptions = new Epub2NcxReaderOptions + { + IgnoreMissingContentForNavigationPoints = false + }, + SpineReaderOptions = new SpineReaderOptions + { + IgnoreMissingManifestItems = false + }, + BookCoverReaderOptions = new BookCoverReaderOptions + { + Epub2MetadataIgnoreMissingManifestItem = true } }; @@ -84,6 +124,7 @@ public class BookService : IBookService _directoryService = directoryService; _imageService = imageService; _mediaErrorService = mediaErrorService; + _pdfComicInfoExtractor = new PdfComicInfoExtractor(_logger, _mediaErrorService); } private static bool HasClickableHrefPart(HtmlNode anchor) @@ -306,8 +347,16 @@ public class BookService : IBookService var imageFile = GetKeyForImage(book, image.Attributes[key].Value); image.Attributes.Remove(key); - // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx - image.Attributes.Add(key, $"{apiBase}" + Uri.EscapeDataString(imageFile)); + + if (!imageFile.StartsWith("http")) + { + // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx + image.Attributes.Add(key, $"{apiBase}" + Uri.EscapeDataString(imageFile)); + } + else + { + image.Attributes.Add(key, imageFile); + } // Add a custom class that the reader uses to ensure images stay within reader parent.AddClass("kavita-scale-width-container"); @@ -350,11 +399,14 @@ public class BookService : IBookService { // Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping var htmlNode = doc.DocumentNode.SelectSingleNode("//html"); - if (htmlNode == null || !htmlNode.Attributes.Contains("class")) return body.InnerHtml; + if (htmlNode == null) return body.InnerHtml; var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty; - var classes = htmlNode.Attributes["class"].Value + " " + bodyClasses; - body.Attributes.Add("class", $"{classes}"); + var htmlClasses = htmlNode.Attributes.Contains("class") ? htmlNode.Attributes["class"].Value : string.Empty; + + body.Attributes.Add("class", $"{htmlClasses} {bodyClasses}"); + + // I actually need the body tag itself for the classes, so i will create a div and put the body stuff there. return $"
{body.InnerHtml}
"; } @@ -425,13 +477,14 @@ public class BookService : IBookService } } - public ComicInfo? GetComicInfo(string filePath) + private ComicInfo? GetEpubComicInfo(string filePath) { - if (!IsValidFile(filePath) || Parser.IsPdf(filePath)) return null; + EpubBookRef? epubBook = null; try { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + epubBook = OpenEpubWithFallback(filePath, epubBook); + var publicationDate = epubBook.Schema.Package.Metadata.Dates.Find(pDate => pDate.Event == "publication")?.Date; @@ -439,10 +492,11 @@ public class BookService : IBookService { publicationDate = epubBook.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date; } + var (year, month, day) = GetPublicationDate(publicationDate); var summary = epubBook.Schema.Package.Metadata.Descriptions.FirstOrDefault(); - var info = new ComicInfo + var info = new ComicInfo { Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description, Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers.Select(p => p.Publisher)), @@ -450,7 +504,8 @@ public class BookService : IBookService Day = day, Year = year, Title = epubBook.Title, - Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())), + Genre = string.Join(",", + epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())), LanguageISO = ValidateLanguage(epubBook.Schema.Package.Metadata.Languages .Select(l => l.Language) .FirstOrDefault()) @@ -461,7 +516,8 @@ public class BookService : IBookService foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers) { if (string.IsNullOrEmpty(identifier.Identifier)) continue; - if (!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase)) + if (!string.IsNullOrEmpty(identifier.Scheme) && + identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase)) { var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty).Replace("isbn:", string.Empty); if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn)) @@ -469,11 +525,13 @@ public class BookService : IBookService _logger.LogDebug("[BookService] {File} has invalid ISBN number", filePath); continue; } + info.Isbn = isbn; } - if ((!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) || - identifier.Identifier.StartsWith("url:")) + if ((!string.IsNullOrEmpty(identifier.Scheme) && + identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) || + identifier.Identifier.StartsWith("url:")) { var url = identifier.Identifier.Replace("url:", string.Empty); weblinks.Add(url.Trim()); @@ -503,6 +561,7 @@ public class BookService : IBookService { info.SeriesSort = metadataItem.Content; } + break; case "calibre:series_index": info.Volume = metadataItem.Content; @@ -522,6 +581,7 @@ public class BookService : IBookService { info.SeriesSort = metadataItem.Content; } + break; case "collection-type": // These look to be genres from https://manual.calibre-ebook.com/sub_groups.html or can be "series" @@ -552,7 +612,8 @@ public class BookService : IBookService } // If this is a single book and not a collection, set publication status to Completed - if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) + if (string.IsNullOrEmpty(info.Volume) && + Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) { info.Count = 1; } @@ -564,7 +625,8 @@ public class BookService : IBookService var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga) .Equals(Parser.LooseLeafVolume); - if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) + if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && + (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) { // This is likely a light novel for which we can set series from parsed title info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga); @@ -575,14 +637,54 @@ public class BookService : IBookService } catch (Exception ex) { - _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata"); + _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata: {FilePath}", filePath); _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, "There was an exception parsing metadata", ex); } + finally + { + epubBook?.Dispose(); + } return null; } + private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook) + { + try + { + epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "[GetComicInfo] There was an exception parsing metadata, falling back to a more lenient parsing method: {FilePath}", + filePath); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception parsing metadata", ex); + } + finally + { + epubBook ??= EpubReader.OpenBook(filePath, LenientBookReaderOptions); + } + + return epubBook; + } + + public ComicInfo? GetComicInfo(string filePath) + { + if (!IsValidFile(filePath)) return null; + + if (Parser.IsPdf(filePath)) + { + return _pdfComicInfoExtractor.GetComicInfo(filePath); + } + else + { + return GetEpubComicInfo(filePath); + } + } + private static void ExtractSortTitle(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info) { var titleId = metadataItem.Refines?.Replace("#", string.Empty); @@ -670,7 +772,7 @@ public class BookService : IBookService var month = 0; var day = 0; if (string.IsNullOrEmpty(publicationDate)) return (year, month, day); - switch (DateTime.TryParse(publicationDate, out var date)) + switch (DateTime.TryParse(publicationDate, CultureInfo.InvariantCulture, out var date)) { case true: year = date.Year; @@ -685,7 +787,7 @@ public class BookService : IBookService return (year, month, day); } - private static string ValidateLanguage(string? language) + public static string ValidateLanguage(string? language) { if (string.IsNullOrEmpty(language)) return string.Empty; @@ -725,7 +827,7 @@ public class BookService : IBookService return docReader.GetPageCount(); } - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions); return epubBook.GetReadingOrder().Count; } catch (Exception ex) @@ -783,7 +885,7 @@ public class BookService : IBookService try { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions); // // @@ -987,7 +1089,7 @@ public class BookService : IBookService /// public async Task> GenerateTableOfContents(Chapter chapter) { - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); var navItems = await book.GetNavigationAsync(); @@ -1115,7 +1217,7 @@ public class BookService : IBookService /// All exceptions throw this public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) { - using var book = await EpubReader.OpenBookAsync(cachedEpubPath, BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; @@ -1217,7 +1319,7 @@ public class BookService : IBookService return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat, size); } - using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(fileFilePath, LenientBookReaderOptions); try { diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 9a8ef64ce..283d4b1ac 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -80,7 +80,6 @@ public class CacheService : ICacheService /// public IEnumerable GetCachedFileDimensions(string cachePath) { - var sw = Stopwatch.StartNew(); var files = _directoryService.GetFilesWithExtension(cachePath, Tasks.Scanner.Parser.Parser.ImageFileExtensions) .OrderByNatural(Path.GetFileNameWithoutExtension) .ToArray(); @@ -173,7 +172,30 @@ public class CacheService : ICacheService await extractLock.WaitAsync(); try { - if(_directoryService.Exists(extractPath)) return chapter; + if (_directoryService.Exists(extractPath)) + { + if (extractPdfToImages) + { + var pdfImages = _directoryService.GetFiles(extractPath, + Tasks.Scanner.Parser.Parser.ImageFileExtensions); + if (pdfImages.Any()) + { + return chapter; + } + } + else + { + // Do an explicit check for files since rarely a "permission denied" error on deleting + // the file can occur, thus leaving an empty folder and we would never re-cache the files. + if (_directoryService.GetFiles(extractPath).Any()) + { + return chapter; + } + + // Delete the extractPath as ExtractArchive will return if the directory already exists + _directoryService.ClearAndDeleteDirectory(extractPath); + } + } var files = chapter?.Files.ToList(); ExtractChapterFiles(extractPath, files, extractPdfToImages); @@ -194,13 +216,13 @@ public class CacheService : ICacheService /// public void ExtractChapterFiles(string extractPath, IReadOnlyList? files, bool extractPdfImages = false) { - if (files == null) return; + if (files == null || files.Count == 0) return; var removeNonImages = true; var fileCount = files.Count; var extraPath = string.Empty; var extractDi = _directoryService.FileSystem.DirectoryInfo.New(extractPath); - if (files.Count > 0 && files[0].Format == MangaFormat.Image) + if (files[0].Format == MangaFormat.Image) { // Check if all the files are Images. If so, do a directory copy, else do the normal copy if (files.All(f => f.Format == MangaFormat.Image)) diff --git a/API/Services/CollectionTagService.cs b/API/Services/CollectionTagService.cs index 645cffcfa..a73c0cea2 100644 --- a/API/Services/CollectionTagService.cs +++ b/API/Services/CollectionTagService.cs @@ -58,7 +58,7 @@ public class CollectionTagService : ICollectionTagService if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, userId)) throw new KavitaException("collection-tag-duplicate"); - existingTag.Items ??= new List(); + existingTag.Items ??= []; if (existingTag.Source == ScrobbleProvider.Kavita) { existingTag.Title = title; @@ -74,7 +74,7 @@ public class CollectionTagService : ICollectionTagService _unitOfWork.CollectionTagRepository.Update(existingTag); // Check if Tag has updated (Summary) - var summary = dto.Summary.Trim(); + var summary = (dto.Summary ?? string.Empty).Trim(); if (existingTag.Summary == null || !existingTag.Summary.Equals(summary)) { existingTag.Summary = summary; @@ -105,7 +105,7 @@ public class CollectionTagService : ICollectionTagService { if (tag == null) return false; - tag.Items ??= new List(); + tag.Items ??= []; tag.Items = tag.Items.Where(s => !seriesIds.Contains(s.Id)).ToList(); if (tag.Items.Count == 0) diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 338ea0537..7e308d92e 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -31,10 +31,18 @@ public interface IDirectoryService string TemplateDirectory { get; } string PublisherDirectory { get; } /// + /// Used for caching documents that may need to stay on disk for more than a day + /// + string LongTermCacheDirectory { get; } + /// /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// string BookmarkDirectory { get; } /// + /// Used for random files needed, like images to check against, list of countries, etc + /// + string AssetsDirectory { get; } + /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. /// /// Absolute path of directory to scan. @@ -61,6 +69,7 @@ public interface IDirectoryService IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly); bool ExistOrCreate(string directoryPath); void DeleteFiles(IEnumerable files); + void CopyFile(string sourcePath, string destinationPath, bool overwrite = true); void RemoveNonImages(string directoryName); void Flatten(string directoryName); Task CheckWriteAccess(string directoryName); @@ -83,12 +92,14 @@ public class DirectoryService : IDirectoryService public string TempDirectory { get; } public string ConfigDirectory { get; } public string BookmarkDirectory { get; } + public string AssetsDirectory { get; } public string SiteThemeDirectory { get; } public string FaviconDirectory { get; } public string LocalizationDirectory { get; } public string CustomizedTemplateDirectory { get; } public string TemplateDirectory { get; } public string PublisherDirectory { get; } + public string LongTermCacheDirectory { get; } private readonly ILogger _logger; private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; @@ -115,6 +126,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(TempDirectory); BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); ExistOrCreate(BookmarkDirectory); + AssetsDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "Assets"); + ExistOrCreate(AssetsDirectory); SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); ExistOrCreate(SiteThemeDirectory); FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons"); @@ -126,6 +139,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(TemplateDirectory); PublisherDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "images", "publishers"); ExistOrCreate(PublisherDirectory); + LongTermCacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache-long"); + ExistOrCreate(LongTermCacheDirectory); } /// @@ -923,6 +938,27 @@ public class DirectoryService : IDirectoryService } } + public void CopyFile(string sourcePath, string destinationPath, bool overwrite = true) + { + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException("Source file not found", sourcePath); + } + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrEmpty(destinationDirectory)) + { + throw new ArgumentException("Destination path does not contain a directory", nameof(destinationPath)); + } + + if (!Directory.Exists(destinationDirectory)) + { + FileSystem.Directory.CreateDirectory(destinationDirectory); + } + + FileSystem.File.Copy(sourcePath, destinationPath, overwrite); + } + /// /// Returns the human-readable file size for an arbitrary, 64-bit file size /// The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB" @@ -1076,4 +1112,23 @@ public class DirectoryService : IDirectoryService FlattenDirectory(root, subDirectory, ref directoryIndex); } } + + /// + /// If the file is locked or not existing + /// + /// + /// + public static bool IsFileLocked(string filePath) + { + try + { + if (!File.Exists(filePath)) return false; + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None); + return false; // If this works, the file is not locked + } + catch (IOException) + { + return true; // File is locked by another process + } + } } diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 0a8ba6404..35cfa7b04 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -4,16 +4,22 @@ using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Net; +using System.Text; using System.Threading.Tasks; using System.Web; using API.Data; using API.DTOs.Email; +using API.Entities; +using API.Services.Plus; using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Extensions; using MailKit.Security; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using MimeKit; +using MimeTypes; namespace API.Services; #nullable enable @@ -29,6 +35,8 @@ internal class EmailOptionsDto /// Filenames to attach /// public IList? Attachments { get; set; } + public int? ToUserId { get; set; } + public required string Template { get; set; } } public interface IEmailService @@ -43,6 +51,10 @@ public interface IEmailService Task GenerateEmailLink(HttpRequest request, string token, string routePart, string email, bool withHost = true); + + Task SendTokenExpiredEmail(int userId, ScrobbleProvider provider); + Task SendTokenExpiringSoonEmail(int userId, ScrobbleProvider provider); + Task SendKavitaPlusDebug(); } public class EmailService : IEmailService @@ -56,6 +68,15 @@ public class EmailService : IEmailService private const string TemplatePath = @"{0}.html"; private const string LocalHost = "localhost:4200"; + public const string SendToDeviceTemplate = "SendToDevice"; + public const string EmailTestTemplate = "EmailTest"; + public const string EmailChangeTemplate = "EmailChange"; + public const string TokenExpirationTemplate = "TokenExpiration"; + public const string TokenExpiringSoonTemplate = "TokenExpiringSoon"; + public const string EmailConfirmTemplate = "EmailConfirm"; + public const string EmailPasswordResetTemplate = "EmailPasswordReset"; + public const string KavitaPlusDebugTemplate = "KavitaPlusDebug"; + public EmailService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, IHostEnvironment environment, ILocalizationService localizationService) { @@ -104,12 +125,13 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = "Kavita - Email Test", - Body = UpdatePlaceHolders(await GetEmailBody("EmailTest"), placeholders), + Template = EmailTestTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailTestTemplate), placeholders), Preheader = "Kavita - Email Test", ToEmails = new List() { adminEmail - } + }, }; await SendEmail(emailOptions); @@ -139,7 +161,8 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("Your email has been changed on {{InvitingUser}}'s Server", placeholders), - Body = UpdatePlaceHolders(await GetEmailBody("EmailChange"), placeholders), + Template = EmailChangeTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailChangeTemplate), placeholders), Preheader = UpdatePlaceHolders("Your email has been changed on {{InvitingUser}}'s Server", placeholders), ToEmails = new List() { @@ -155,9 +178,9 @@ public class EmailService : IEmailService /// /// /// - public bool IsValidEmail(string email) + public bool IsValidEmail(string? email) { - return new EmailAddressAttribute().IsValid(email); + return !string.IsNullOrEmpty(email) && new EmailAddressAttribute().IsValid(email); } public async Task GenerateEmailLink(HttpRequest request, string token, string routePart, string email, bool withHost = true) @@ -180,6 +203,100 @@ public class EmailService : IEmailService .Replace("//", "/"); } + public async Task SendTokenExpiredEmail(int userId, ScrobbleProvider provider) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (user == null || !IsValidEmail(user.Email) || !settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{UserName}}", user.UserName!), + new ("{{Provider}}", provider.ToDescription()), + new ("{{Link}}", $"{settings.HostName}/settings#account" ), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita - Your {{Provider}} token has expired and scrobbling events have stopped", placeholders), + Template = TokenExpirationTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(TokenExpirationTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita - Your {{Provider}} token has expired and scrobbling events have stopped", placeholders), + ToEmails = new List() + { + user.Email + } + }; + + await SendEmail(emailOptions); + + return true; + } + + public async Task SendTokenExpiringSoonEmail(int userId, ScrobbleProvider provider) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (user == null || !IsValidEmail(user.Email) || !settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{UserName}}", user.UserName!), + new ("{{Provider}}", provider.ToDescription()), + new ("{{Link}}", $"{settings.HostName}/settings#account" ), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita - Your {{Provider}} token will expire soon!", placeholders), + Template = TokenExpiringSoonTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(TokenExpiringSoonTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita - Your {{Provider}} token will expire soon!", placeholders), + ToEmails = new List() + { + user.Email + } + }; + + await SendEmail(emailOptions); + + return true; + } + + /// + /// Sends information about Kavita install for Kavita+ registration + /// + /// Users in China can have issues subscribing, this flow will allow me to register their instance on their behalf + /// + public async Task SendKavitaPlusDebug() + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{InstallId}}", HashUtil.ServerToken()), + new ("{{Build}}", BuildInfo.Version.ToString()), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita+: A User needs manual registration", placeholders), + Template = KavitaPlusDebugTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(KavitaPlusDebugTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita+: A User needs manual registration", placeholders), + ToEmails = + [ + // My kavita email + Encoding.UTF8.GetString(Convert.FromBase64String("a2F2aXRhcmVhZGVyQGdtYWlsLmNvbQ==")) + ] + }; + + await SendEmail(emailOptions); + + return true; + } + /// /// Sends an invite email to a user to setup their account /// @@ -195,7 +312,8 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), - Body = UpdatePlaceHolders(await GetEmailBody("EmailConfirm"), placeholders), + Template = EmailConfirmTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailConfirmTemplate), placeholders), Preheader = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), ToEmails = new List() { @@ -221,12 +339,13 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("A password reset has been requested", placeholders), - Body = UpdatePlaceHolders(await GetEmailBody("EmailPasswordReset"), placeholders), - Preheader = "A password reset has been requested", - ToEmails = new List() - { + Template = EmailPasswordResetTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailPasswordResetTemplate), placeholders), + Preheader = "Email confirmation is required for continued access. Click the button to confirm your email.", + ToEmails = + [ dto.EmailAddress - } + ] }; await SendEmail(emailOptions); @@ -242,11 +361,9 @@ public class EmailService : IEmailService { Subject = "Send file from Kavita", Preheader = "File(s) sent from Kavita", - ToEmails = new List() - { - data.DestinationEmail - }, - Body = await GetEmailBody("SendToDevice"), + ToEmails = [data.DestinationEmail], + Template = SendToDeviceTemplate, + Body = await GetEmailBody(SendToDeviceTemplate), Attachments = data.FilePaths.ToList() }; @@ -277,9 +394,21 @@ public class EmailService : IEmailService if (userEmailOptions.Attachments != null) { - foreach (var attachment in userEmailOptions.Attachments) + foreach (var attachmentPath in userEmailOptions.Attachments) { - await body.Attachments.AddAsync(attachment); + var mimeType = MimeTypeMap.GetMimeType(attachmentPath) ?? "application/octet-stream"; + var mediaType = mimeType.Split('/')[0]; + var mediaSubtype = mimeType.Split('/')[1]; + + var attachment = new MimePart(mediaType, mediaSubtype) + { + Content = new MimeContent(File.OpenRead(attachmentPath)), + ContentDisposition = new ContentDisposition(ContentDisposition.Attachment), + ContentTransferEncoding = ContentEncoding.Base64, + FileName = Path.GetFileName(attachmentPath) + }; + + body.Attachments.Add(attachment); } } @@ -302,21 +431,66 @@ public class EmailService : IEmailService ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; + var emailAddress = userEmailOptions.ToEmails[0]; + AppUser? user; + if (userEmailOptions.Template == SendToDeviceTemplate) + { + user = await _unitOfWork.UserRepository.GetUserByDeviceEmail(emailAddress); + } + else + { + user = await _unitOfWork.UserRepository.GetUserByEmailAsync(emailAddress); + } + + try { await smtpClient.SendAsync(email); + if (user != null) + { + await LogEmailHistory(user.Id, userEmailOptions.Template, userEmailOptions.Subject, userEmailOptions.Body, "Sent"); + } } catch (Exception ex) { _logger.LogError(ex, "There was an issue sending the email"); + + if (user != null) + { + await LogEmailHistory(user.Id, userEmailOptions.Template, userEmailOptions.Subject, userEmailOptions.Body, "Failed", ex.Message); + } + _logger.LogError("Could not find user on file for email, {Template} email was not sent and not recorded into history table", userEmailOptions.Template); + throw; } finally { await smtpClient.DisconnectAsync(true); + } } + /// + /// Logs email history for the specified user. + /// + private async Task LogEmailHistory(int appUserId, string emailTemplate, string subject, string body, string deliveryStatus, string? errorMessage = null) + { + var emailHistory = new EmailHistory + { + AppUserId = appUserId, + EmailTemplate = emailTemplate, + Sent = deliveryStatus == "Sent", + Body = body, + Subject = subject, + SendDate = DateTime.UtcNow, + DeliveryStatus = deliveryStatus, + ErrorMessage = errorMessage + }; + + _unitOfWork.DataContext.EmailHistory.Add(emailHistory); + await _unitOfWork.CommitAsync(); + } + private async Task GetTemplatePath(string templateName) { if ((await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).SmtpConfig.CustomizedTemplates) diff --git a/API/Services/FileService.cs b/API/Services/FileService.cs index 37222655a..2cb34c601 100644 --- a/API/Services/FileService.cs +++ b/API/Services/FileService.cs @@ -70,7 +70,6 @@ public class FileService : IFileService // Compute SHA hash var checksum = SHA256.HashData(Encoding.UTF8.GetBytes(content)); - return BitConverter.ToString(checksum).Replace("-", string.Empty).Equals(sha); - + return Convert.ToHexString(checksum).Equals(sha); } } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index c1c3ea71f..544efa4ce 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -1,21 +1,13 @@ using System; using System.Collections.Generic; -using System.Drawing; using System.IO; using System.Linq; using System.Numerics; using System.Threading.Tasks; -using API.Constants; using API.DTOs; -using API.Entities; using API.Entities.Enums; using API.Entities.Interfaces; using API.Extensions; -using EasyCaching.Core; -using Flurl; -using Flurl.Http; -using HtmlAgilityPack; -using Kavita.Common; using Microsoft.Extensions.Logging; using NetVips; using SixLabors.ImageSharp.PixelFormats; @@ -58,6 +50,7 @@ public interface IImageService /// /// string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); + /// /// Converts the passed image to encoding and outputs it in the same directory /// @@ -601,6 +594,7 @@ public class ImageService : IImageService return string.Empty; } + /// /// Returns the name format for a chapter cover image /// @@ -753,7 +747,8 @@ public class ImageService : IImageService entity.SecondaryColor = colors.Secondary; } - public static Color HexToRgb(string? hex) + + public static (int R, int G, int B) HexToRgb(string? hex) { if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null"); @@ -777,7 +772,7 @@ public class ImageService : IImageService var g = Convert.ToInt32(hex.Substring(2, 2), 16); var b = Convert.ToInt32(hex.Substring(4, 2), 16); - return Color.FromArgb(r, g, b); + return (r, g, b); } diff --git a/API/Services/KoreaderService.cs b/API/Services/KoreaderService.cs new file mode 100644 index 000000000..a38e8c468 --- /dev/null +++ b/API/Services/KoreaderService.cs @@ -0,0 +1,91 @@ +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Koreader; +using API.DTOs.Progress; +using API.Extensions; +using API.Helpers; +using API.Helpers.Builders; +using Kavita.Common; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +#nullable enable + +public interface IKoreaderService +{ + Task SaveProgress(KoreaderBookDto koreaderBookDto, int userId); + Task GetProgress(string bookHash, int userId); +} + +public class KoreaderService : IKoreaderService +{ + private readonly IReaderService _readerService; + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; + + public KoreaderService(IReaderService readerService, IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger logger) + { + _readerService = readerService; + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _logger = logger; + } + + /// + /// Given a Koreader hash, locate the underlying file and generate/update a progress event. + /// + /// + /// + public async Task SaveProgress(KoreaderBookDto koreaderBookDto, int userId) + { + _logger.LogDebug("Saving Koreader progress for User ({UserId}): {KoreaderProgress}", userId, koreaderBookDto.Progress.Sanitize()); + var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(koreaderBookDto.Document); + if (file == null) throw new KavitaException(await _localizationService.Translate(userId, "file-missing")); + + var userProgressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId); + if (userProgressDto == null) + { + var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(file.ChapterId); + if (chapterDto == null) throw new KavitaException(await _localizationService.Translate(userId, "chapter-doesnt-exist")); + + var volumeDto = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapterDto.VolumeId); + if (volumeDto == null) throw new KavitaException(await _localizationService.Translate(userId, "volume-doesnt-exist")); + + userProgressDto = new ProgressDto() + { + ChapterId = file.ChapterId, + VolumeId = chapterDto.VolumeId, + SeriesId = volumeDto.SeriesId, + }; + } + // Update the bookScrollId if possible + KoreaderHelper.UpdateProgressDto(userProgressDto, koreaderBookDto.Progress); + + await _readerService.SaveReadingProgress(userProgressDto, userId); + } + + /// + /// Returns a Koreader Dto representing current book and the progress within + /// + /// + /// + /// + public async Task GetProgress(string bookHash, int userId) + { + var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + + var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(bookHash); + + if (file == null) throw new KavitaException(await _localizationService.Translate(userId, "file-missing")); + + var progressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId); + var koreaderProgress = KoreaderHelper.GetKoreaderPosition(progressDto); + + return new KoreaderBookDtoBuilder(bookHash).WithProgress(koreaderProgress) + .WithPercentage(progressDto?.PageNum, file.Pages) + .WithDeviceId(settingsDto.InstallId, userId) + .Build(); + } +} diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index ab3ad3d89..7db35bb8e 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using API.Data; +using API.DTOs; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; @@ -11,11 +12,13 @@ namespace API.Services; #nullable enable + + public interface ILocalizationService { Task Get(string locale, string key, params object[] args); Task Translate(int userId, string key, params object[] args); - IEnumerable GetLocales(); + IEnumerable GetLocales(); } public class LocalizationService : ILocalizationService @@ -134,14 +137,260 @@ public class LocalizationService : ILocalizationService /// Returns all available locales that exist on both the Frontend and the Backend ///
/// - public IEnumerable GetLocales() + public IEnumerable GetLocales() { var uiLanguages = _directoryService - .GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json") - .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty)); + .GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json"); var backendLanguages = _directoryService - .GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json") - .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty)); - return uiLanguages.Intersect(backendLanguages).Distinct(); + .GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json"); + + var locales = new Dictionary(); + var localeCounts = new Dictionary>(); // fileName -> (nonEmptyValues, totalKeys) + + // First pass: collect all files and count non-empty strings + + // Process UI language files + foreach (var file in uiLanguages) + { + var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file); + var fileContent = _directoryService.FileSystem.File.ReadAllText(file); + var hash = ComputeHash(fileContent); + + var counts = CalculateNonEmptyStrings(fileContent); + + if (localeCounts.TryGetValue(fileName, out var existingCount)) + { + // Update existing counts + localeCounts[fileName] = Tuple.Create( + existingCount.Item1 + counts.Item1, + existingCount.Item2 + counts.Item2 + ); + } + else + { + // Add new counts + localeCounts[fileName] = counts; + } + + if (!locales.TryGetValue(fileName, out var locale)) + { + locales[fileName] = new KavitaLocale + { + FileName = fileName, + RenderName = GetDisplayName(fileName), + TranslationCompletion = 0, // Will be calculated later + IsRtL = IsRightToLeft(fileName), + Hash = hash + }; + } + else + { + // Update existing locale hash + locale.Hash = CombineHashes(locale.Hash, hash); + } + } + + // Process backend language files + foreach (var file in backendLanguages) + { + var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file); + var fileContent = _directoryService.FileSystem.File.ReadAllText(file); + var hash = ComputeHash(fileContent); + + var counts = CalculateNonEmptyStrings(fileContent); + + if (localeCounts.TryGetValue(fileName, out var existingCount)) + { + // Update existing counts + localeCounts[fileName] = Tuple.Create( + existingCount.Item1 + counts.Item1, + existingCount.Item2 + counts.Item2 + ); + } + else + { + // Add new counts + localeCounts[fileName] = counts; + } + + if (!locales.TryGetValue(fileName, out var locale)) + { + locales[fileName] = new KavitaLocale + { + FileName = fileName, + RenderName = GetDisplayName(fileName), + TranslationCompletion = 0, // Will be calculated later + IsRtL = IsRightToLeft(fileName), + Hash = hash + }; + } + else + { + // Update existing locale hash + locale.Hash = CombineHashes(locale.Hash, hash); + } + } + + // Second pass: calculate completion percentages based on English total + if (localeCounts.TryGetValue("en", out var englishCounts) && englishCounts.Item2 > 0) + { + var englishTotalKeys = englishCounts.Item2; + + foreach (var locale in locales.Values) + { + if (localeCounts.TryGetValue(locale.FileName, out var counts)) + { + // Calculate percentage based on English total keys + locale.TranslationCompletion = (float)counts.Item1 / englishTotalKeys * 100; + } + } + } + + return locales.Values; + } + + // Helper methods that would need to be implemented + private static string ComputeHash(string content) + { + // Implement a hashing algorithm (e.g., SHA256, MD5) to generate a hash for the content + using var md5 = System.Security.Cryptography.MD5.Create(); + var inputBytes = System.Text.Encoding.UTF8.GetBytes(content); + var hashBytes = md5.ComputeHash(inputBytes); + return Convert.ToBase64String(hashBytes); + } + + private static string CombineHashes(string hash1, string hash2) + { + // Combine two hashes, possibly by concatenating and rehashing + return ComputeHash(hash1 + hash2); + } + + private static string GetDisplayName(string fileName) + { + // Map the filename to a human-readable display name + // This could use a lookup table or follow a naming convention + try + { + var cultureInfo = new System.Globalization.CultureInfo(fileName.Replace('_', '-')); + return cultureInfo.NativeName; + } + catch + { + // Fall back to the file name if the culture isn't recognized + return fileName; + } + } + + private static bool IsRightToLeft(string fileName) + { + // Determine if the language is right-to-left + try + { + var cultureInfo = new System.Globalization.CultureInfo(fileName); + return cultureInfo.TextInfo.IsRightToLeft; + } + catch + { + return false; // Default to left-to-right + } + } + + private static float CalculateTranslationCompletion(string fileContent) + { + try + { + var jsonObject = System.Text.Json.JsonDocument.Parse(fileContent); + + int totalKeys = 0; + int nonEmptyValues = 0; + + // Count all keys and non-empty values + CountNonEmptyValues(jsonObject.RootElement, ref totalKeys, ref nonEmptyValues); + + return totalKeys > 0 ? (nonEmptyValues * 1f) / totalKeys * 100 : 0; + } + catch (Exception ex) + { + // Consider logging the exception + return 0; // Return 0% completion if there's an error parsing + } + } + private static Tuple CalculateNonEmptyStrings(string fileContent) + { + try + { + var jsonObject = JsonDocument.Parse(fileContent); + + var totalKeys = 0; + var nonEmptyValues = 0; + + // Count all keys and non-empty values + CountNonEmptyValues(jsonObject.RootElement, ref totalKeys, ref nonEmptyValues); + + return Tuple.Create(nonEmptyValues, totalKeys); + } + catch (Exception) + { + // Consider logging the exception + return Tuple.Create(0, 0); // Return 0% completion if there's an error parsing + } + } + + private static void CountNonEmptyValues(JsonElement element, ref int totalKeys, ref int nonEmptyValues) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (property.Value.ValueKind == System.Text.Json.JsonValueKind.String) + { + totalKeys++; + var value = property.Value.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + nonEmptyValues++; + } + } + else + { + // Recursively process nested objects + CountNonEmptyValues(property.Value, ref totalKeys, ref nonEmptyValues); + } + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + CountNonEmptyValues(item, ref totalKeys, ref nonEmptyValues); + } + } + } + + private void CountEntries(System.Text.Json.JsonElement element, ref int total, ref int translated) + { + if (element.ValueKind == System.Text.Json.JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + CountEntries(property.Value, ref total, ref translated); + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + CountEntries(item, ref total, ref translated); + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.String) + { + total++; + string value = element.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + translated++; + } + } } } diff --git a/API/Services/MediaConversionService.cs b/API/Services/MediaConversionService.cs index 9f6b18374..fc3e5f318 100644 --- a/API/Services/MediaConversionService.cs +++ b/API/Services/MediaConversionService.cs @@ -222,6 +222,10 @@ public class MediaConversionService : IMediaConversionService { if (string.IsNullOrEmpty(series.CoverImage)) continue; series.CoverImage = series.GetCoverImage(); + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); + } _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 7a406fe48..e0e86f4dc 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -28,7 +28,7 @@ public interface IMetadataService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false, bool forceColorScape = false); /// - /// Performs a forced refresh of cover images just for a series and it's nested entities + /// Performs a forced refresh of cover images just for a series, and it's nested entities /// /// /// @@ -75,12 +75,12 @@ public class MetadataService : IMetadataService /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image /// Convert image to Encoding Format when extracting the cover /// Force colorscape gen - private Task UpdateChapterCoverImage(Chapter? chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) + private bool UpdateChapterCoverImage(Chapter? chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) { - if (chapter == null) return Task.FromResult(false); + if (chapter == null) return false; var firstFile = chapter.Files.MinBy(x => x.Chapter); - if (firstFile == null) return Task.FromResult(false); + if (firstFile == null) return false; if (!_cacheHelper.ShouldUpdateCoverImage( _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), @@ -93,7 +93,7 @@ public class MetadataService : IMetadataService _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); } - return Task.FromResult(false); + return false; } @@ -107,7 +107,7 @@ public class MetadataService : IMetadataService _unitOfWork.ChapterRepository.Update(chapter); _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); - return Task.FromResult(true); + return true; } private void UpdateChapterLastModified(Chapter chapter, bool forceUpdate) @@ -135,10 +135,10 @@ public class MetadataService : IMetadataService /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image /// Force updating colorscape - private Task UpdateVolumeCoverImage(Volume? volume, bool forceUpdate, bool forceColorScape = false) + private bool UpdateVolumeCoverImage(Volume? volume, bool forceUpdate, bool forceColorScape = false) { // We need to check if Volume coverImage matches first chapters if forceUpdate is false - if (volume == null) return Task.FromResult(false); + if (volume == null) return false; if (!_cacheHelper.ShouldUpdateCoverImage( _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage), @@ -150,7 +150,7 @@ public class MetadataService : IMetadataService _unitOfWork.VolumeRepository.Update(volume); _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); } - return Task.FromResult(false); + return false; } if (!volume.CoverImageLocked) @@ -162,7 +162,7 @@ public class MetadataService : IMetadataService if (firstChapter == null) { firstChapter = volume.Chapters.MinBy(x => x.SortOrder, ChapterSortComparerDefaultFirst.Default); - if (firstChapter == null) return Task.FromResult(false); + if (firstChapter == null) return false; } volume.CoverImage = firstChapter.CoverImage; @@ -171,7 +171,7 @@ public class MetadataService : IMetadataService _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); - return Task.FromResult(true); + return true; } /// @@ -179,9 +179,9 @@ public class MetadataService : IMetadataService /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - private Task UpdateSeriesCoverImage(Series? series, bool forceUpdate, bool forceColorScape = false) + private void UpdateSeriesCoverImage(Series? series, bool forceUpdate, bool forceColorScape = false) { - if (series == null) return Task.CompletedTask; + if (series == null) return; if (!_cacheHelper.ShouldUpdateCoverImage( _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), @@ -194,16 +194,19 @@ public class MetadataService : IMetadataService _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); } - return Task.CompletedTask; + return; } series.Volumes ??= []; series.CoverImage = series.GetCoverImage(); + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); + } _imageService.UpdateColorScape(series); _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); - return Task.CompletedTask; } @@ -213,7 +216,7 @@ public class MetadataService : IMetadataService /// /// /// - private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) + private void ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) { _logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName); try @@ -226,8 +229,8 @@ public class MetadataService : IMetadataService var index = 0; foreach (var chapter in volume.Chapters) { - var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize, forceColorScape); - // If cover was update, either the file has changed or first scan and we should force a metadata update + var chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize, forceColorScape); + // If cover was update, either the file has changed or first scan, and we should force a metadata update UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated); if (index == 0 && chapterUpdated) { @@ -237,7 +240,7 @@ public class MetadataService : IMetadataService index++; } - var volumeUpdated = await UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate, forceColorScape); + var volumeUpdated = UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate, forceColorScape); if (volumeIndex == 0 && volumeUpdated) { firstVolumeUpdated = true; @@ -245,7 +248,7 @@ public class MetadataService : IMetadataService volumeIndex++; } - await UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate, forceColorScape); + UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate, forceColorScape); } catch (Exception ex) { @@ -311,7 +314,7 @@ public class MetadataService : IMetadataService try { - await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); + ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); } catch (Exception ex) { @@ -383,7 +386,7 @@ public class MetadataService : IMetadataService await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name)); - await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); + ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); if (_unitOfWork.HasChanges()) diff --git a/API/Services/PersonService.cs b/API/Services/PersonService.cs new file mode 100644 index 000000000..ff0049cbe --- /dev/null +++ b/API/Services/PersonService.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities.Person; +using API.Extensions; +using API.Helpers.Builders; + +namespace API.Services; + +public interface IPersonService +{ + /// + /// Adds src as an alias to dst, this is a destructive operation + /// + /// Merged person + /// Remaining person + /// The entities passed as arguments **must** include all relations + /// + Task MergePeopleAsync(Person src, Person dst); + + /// + /// Adds the alias to the person, requires that the aliases are not shared with anyone else + /// + /// This method does NOT commit changes + /// + /// + /// + Task UpdatePersonAliasesAsync(Person person, IList aliases); +} + +public class PersonService(IUnitOfWork unitOfWork): IPersonService +{ + + public async Task MergePeopleAsync(Person src, Person dst) + { + if (dst.Id == src.Id) return; + + if (string.IsNullOrWhiteSpace(dst.Description) && !string.IsNullOrWhiteSpace(src.Description)) + { + dst.Description = src.Description; + } + + if (dst.MalId == 0 && src.MalId != 0) + { + dst.MalId = src.MalId; + } + + if (dst.AniListId == 0 && src.AniListId != 0) + { + dst.AniListId = src.AniListId; + } + + if (dst.HardcoverId == null && src.HardcoverId != null) + { + dst.HardcoverId = src.HardcoverId; + } + + if (dst.Asin == null && src.Asin != null) + { + dst.Asin = src.Asin; + } + + if (dst.CoverImage == null && src.CoverImage != null) + { + dst.CoverImage = src.CoverImage; + } + + MergeChapterPeople(dst, src); + MergeSeriesMetadataPeople(dst, src); + + dst.Aliases.Add(new PersonAliasBuilder(src.Name).Build()); + + foreach (var alias in src.Aliases) + { + dst.Aliases.Add(alias); + } + + unitOfWork.PersonRepository.Remove(src); + unitOfWork.PersonRepository.Update(dst); + await unitOfWork.CommitAsync(); + } + + private static void MergeChapterPeople(Person dst, Person src) + { + + foreach (var chapter in src.ChapterPeople) + { + var alreadyPresent = dst.ChapterPeople + .Any(x => x.ChapterId == chapter.ChapterId && x.Role == chapter.Role); + + if (alreadyPresent) continue; + + dst.ChapterPeople.Add(new ChapterPeople + { + Role = chapter.Role, + ChapterId = chapter.ChapterId, + Person = dst, + KavitaPlusConnection = chapter.KavitaPlusConnection, + OrderWeight = chapter.OrderWeight, + }); + } + } + + private static void MergeSeriesMetadataPeople(Person dst, Person src) + { + foreach (var series in src.SeriesMetadataPeople) + { + var alreadyPresent = dst.SeriesMetadataPeople + .Any(x => x.SeriesMetadataId == series.SeriesMetadataId && x.Role == series.Role); + + if (alreadyPresent) continue; + + dst.SeriesMetadataPeople.Add(new SeriesMetadataPeople + { + SeriesMetadataId = series.SeriesMetadataId, + Role = series.Role, + Person = dst, + KavitaPlusConnection = series.KavitaPlusConnection, + OrderWeight = series.OrderWeight, + }); + } + } + + public async Task UpdatePersonAliasesAsync(Person person, IList aliases) + { + var normalizedAliases = aliases + .Select(a => a.ToNormalized()) + .Where(a => !string.IsNullOrEmpty(a) && a != person.NormalizedName) + .ToList(); + + if (normalizedAliases.Count == 0) + { + person.Aliases = []; + return true; + } + + var others = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedAliases); + others = others.Where(p => p.Id != person.Id).ToList(); + + if (others.Count != 0) return false; + + person.Aliases = aliases.Select(a => new PersonAliasBuilder(a).Build()).ToList(); + + return true; + } +} diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 4bc55eb88..0777e1baa 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -1,58 +1,47 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Collection; +using API.DTOs.KavitaPlus.ExternalMetadata; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Metadata.Matching; +using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Helpers; +using API.Helpers.Builders; +using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; +using API.SignalR; using AutoMapper; using Flurl.Http; using Hangfire; using Kavita.Common; -using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; namespace API.Services.Plus; #nullable enable -/// -/// Used for matching and fetching metadata on a series -/// -internal class ExternalMetadataIdsDto -{ - public long? MalId { get; set; } - public int? AniListId { get; set; } - public string? SeriesName { get; set; } - public string? LocalizedSeriesName { get; set; } - public MediaFormat? PlusMediaFormat { get; set; } = MediaFormat.Unknown; -} - -internal class SeriesDetailPlusApiDto -{ - public IEnumerable Recommendations { get; set; } - public IEnumerable Reviews { get; set; } - public IEnumerable Ratings { get; set; } - public int? AniListId { get; set; } - public long? MalId { get; set; } -} public interface IExternalMetadataService { Task GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId); - Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType); - Task ForceKavitaPlusRefresh(int seriesId); + Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType); Task FetchExternalDataTask(); /// /// This is an entry point and provides a level of protection against calling upstream API. Will only allow 100 new @@ -60,10 +49,14 @@ public interface IExternalMetadataService /// /// /// - /// - Task GetNewSeriesData(int seriesId, LibraryType libraryType); + /// If the fetch was made + Task FetchSeriesMetadata(int seriesId, LibraryType libraryType); Task> GetStacksForUser(int userId); + Task> MatchSeries(MatchSeriesDto dto); + Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId); + Task UpdateSeriesDontMatch(int seriesId, bool dontMatch); + Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId); } public class ExternalMetadataService : IExternalMetadataService @@ -72,28 +65,38 @@ public class ExternalMetadataService : IExternalMetadataService private readonly ILogger _logger; private readonly IMapper _mapper; private readonly ILicenseService _licenseService; + private readonly IScrobblingService _scrobblingService; + private readonly IEventHub _eventHub; + private readonly ICoverDbService _coverDbService; + private readonly IKavitaPlusApiService _kavitaPlusApiService; private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30); - public static readonly ImmutableArray NonEligibleLibraryTypes = ImmutableArray.Create - (LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine); + public static readonly HashSet NonEligibleLibraryTypes = + [LibraryType.Comic, LibraryType.Book, LibraryType.Image]; private readonly SeriesDetailPlusDto _defaultReturn = new() { + Series = null, Recommendations = null, - Ratings = ArraySegment.Empty, - Reviews = ArraySegment.Empty + Ratings = [], + Reviews = [] }; // Allow 50 requests per 24 hours private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); + private static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$"); - public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, ILicenseService licenseService) + public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, + ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService, + IKavitaPlusApiService kavitaPlusApiService) { _unitOfWork = unitOfWork; _logger = logger; _mapper = mapper; _licenseService = licenseService; + _scrobblingService = scrobblingService; + _eventHub = eventHub; + _coverDbService = coverDbService; + _kavitaPlusApiService = kavitaPlusApiService; - - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } /// @@ -110,75 +113,58 @@ public class ExternalMetadataService : IExternalMetadataService /// This is a task that runs on a schedule and slowly fetches data from Kavita+ to keep /// data in the DB non-stale and fetched. /// - /// To avoid blasting Kavita+ API, this only processes a few records. The goal is to slowly build + /// To avoid blasting Kavita+ API, this only processes 25 records. The goal is to slowly build out/refresh the data /// [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task FetchExternalDataTask() { // Find all Series that are eligible and limit - var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeriesIdsWithoutMetadata(25); + var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25); if (ids.Count == 0) return; + ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, true); - _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count); + _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+: {Ids}", ids.Count, string.Join(',', ids)); var count = 0; + var successfulMatches = new List(); var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids); foreach (var seriesId in ids) { var libraryType = libTypes[seriesId]; - await GetNewSeriesData(seriesId, libraryType); - await Task.Delay(1500); - count++; + var success = await FetchSeriesMetadata(seriesId, libraryType); + if (success) + { + count++; + successfulMatches.Add(seriesId); + } + await Task.Delay(6000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request } - _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count); + _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} / {Total} series data from Kavita+: {Ids}", count, ids.Count, string.Join(',', successfulMatches)); } - /// - /// Removes from Blacklist and Invalidates the cache - /// - /// - /// - public async Task ForceKavitaPlusRefresh(int seriesId) - { - if (!await _licenseService.HasActiveLicense()) return; - var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId); - if (!IsPlusEligible(libraryType)) return; - - // Remove from Blacklist if applicable - await _unitOfWork.ExternalSeriesMetadataRepository.RemoveFromBlacklist(seriesId); - - var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId); - if (metadata == null) return; - - metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache); - await _unitOfWork.CommitAsync(); - } /// /// Fetches data from Kavita+ /// /// /// - public async Task GetNewSeriesData(int seriesId, LibraryType libraryType) + /// If a successful match was made + public async Task FetchSeriesMetadata(int seriesId, LibraryType libraryType) { - if (!IsPlusEligible(libraryType)) return; - if (!await _licenseService.HasActiveLicense()) return; + if (!IsPlusEligible(libraryType)) return false; + if (!await _licenseService.HasActiveLicense()) return false; // Generate key based on seriesId and libraryType or any unique identifier for the request // Check if the request is allowed based on the rate limit if (!RateLimiter.TryAcquire(string.Empty)) { // Request not allowed due to rate limit - _logger.LogDebug("Rate Limit hit for Kavita+ prefetch"); - return; + _logger.LogInformation("Rate Limit hit for Kavita+ prefetch"); + return false; } - _logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId); // Prefetch SeriesDetail data - await GetSeriesDetailPlus(seriesId, libraryType); - - // TODO: Fetch Series Metadata (Summary, etc) - + return await GetSeriesDetailPlus(seriesId, libraryType) != null; } public async Task> GetStacksForUser(int userId) @@ -197,15 +183,7 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName); var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var result = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={user.MalUserName}") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .GetJsonAsync>(); + var result = await _kavitaPlusApiService.GetMalStacks(user.MalUserName, license); if (result == null) { @@ -221,6 +199,75 @@ public class ExternalMetadataService : IExternalMetadataService } } + /// + /// Returns the match results for a Series from UI Flow + /// + /// + /// Will extract alternative names like Localized name, year will send as ReleaseYear but fallback to Comic Vine syntax if applicable + /// + /// + /// + public async Task> MatchSeries(MatchSeriesDto dto) + { + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, + SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata | SeriesIncludes.Library); + if (series == null) return []; + + var potentialAnilistId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.AniListWeblinkWebsite); + var potentialMalId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.MalWeblinkWebsite); + + var format = series.Library.Type.ConvertToPlusMediaFormat(series.Format); + var otherNames = ExtractAlternativeNames(series); + + var year = series.Metadata.ReleaseYear; + if (year == 0 && format == PlusMediaFormat.Comic && !string.IsNullOrWhiteSpace(series.Name)) + { + var potentialYear = Parser.ParseYear(series.Name); + if (!string.IsNullOrEmpty(potentialYear)) + { + year = int.Parse(potentialYear); + } + } + + var matchRequest = new MatchSeriesRequestDto() + { + Format = format, + Query = dto.Query, + SeriesName = series.Name, + AlternativeNames = otherNames, + Year = year, + AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), + MalId = potentialMalId ?? ScrobblingService.GetMalId(series) + }; + + try + { + var results = await _kavitaPlusApiService.MatchSeries(matchRequest); + + // Some summaries can contain multiple
s, we need to ensure it's only 1 + foreach (var result in results) + { + result.Series.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(result.Series.Summary)); + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error happened during the request to Kavita+ API"); + } + + return ArraySegment.Empty; + } + + private static List ExtractAlternativeNames(Series series) + { + List altNames = [series.LocalizedName, series.OriginalName]; + return altNames.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); + } + + /// /// Retrieves Metadata about a Recommended External Series /// @@ -237,9 +284,7 @@ public class ExternalMetadataService : IExternalMetadataService } // This is for the Series drawer. We can get this extra information during the initial SeriesDetail call so it's all coming from the DB - - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var details = await GetSeriesDetail(license, aniListId, malId, seriesId); + var details = await GetSeriesDetail(aniListId, malId, seriesId); return details; @@ -249,16 +294,18 @@ public class ExternalMetadataService : IExternalMetadataService /// Returns Series Detail data from Kavita+ - Review, Recs, Ratings ///
/// + /// /// - public async Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType) + public async Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType) { if (!IsPlusEligible(libraryType) || !await _licenseService.HasActiveLicense()) return _defaultReturn; - // Check blacklist (bad matches) - if (await _unitOfWork.ExternalSeriesMetadataRepository.IsBlacklistedSeries(seriesId)) return _defaultReturn; + // Check blacklist (bad matches) or if there is a don't match + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null || !series.WillScrobble()) return _defaultReturn; var needsRefresh = - await _unitOfWork.ExternalSeriesMetadataRepository.ExternalSeriesMetadataNeedsRefresh(seriesId); + await _unitOfWork.ExternalSeriesMetadataRepository.NeedsDataRefresh(seriesId); if (!needsRefresh) { @@ -266,28 +313,172 @@ public class ExternalMetadataService : IExternalMetadataService return await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesDetailPlusDto(seriesId); } + var data = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(seriesId); + if (data == null) return _defaultReturn; + + // Get from Kavita+ API the Full Series metadata with rec/rev and cache to ExternalMetadata tables try { - var data = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(seriesId); - if (data == null) return _defaultReturn; - _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", data.SeriesName); + return await FetchExternalMetadataForSeries(seriesId, libraryType, data); + } + catch (KavitaException ex) + { + _logger.LogError(ex, "Rate limit hit fetching metadata"); + // This can happen when we hit rate limit + return _defaultReturn; + } + } - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(data) - .ReceiveJson(); + /// + /// This will override any sort of matching that was done prior and force it to be what the user Selected + /// + /// + /// + /// + /// + public async Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null) return; + + // Remove from Blacklist + series.IsBlacklisted = false; + series.DontMatch = false; + _unitOfWork.SeriesRepository.Update(series); + + // Refetch metadata with a Direct lookup + try + { + var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type, + new PlusSeriesRequestDto() + { + AniListId = aniListId, + MalId = malId, + CbrId = cbrId, + MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), + SeriesName = series.Name // Required field, not used since AniList/Mal Id are passed + }); + + if (metadata.Series == null) + { + _logger.LogError("Unable to Match {SeriesName} with Kavita+ Series with Id: {AniListId}/{MalId}/{CbrId}", + series.Name, aniListId, malId, cbrId); + return; + } + + // Find all scrobble events and rewrite them to be the correct + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + _unitOfWork.ScrobbleRepository.Remove(events); + + // Find all scrobble errors and remove them + var errors = await _unitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(seriesId); + _unitOfWork.ScrobbleRepository.Remove(errors); + + await _unitOfWork.CommitAsync(); + + // Regenerate all events for the series for all users + BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistoryForSeries(seriesId)); + + // Name can be null on Series even with a direct match + _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, + metadata.Series.Name); + } + catch (KavitaException ex) + { + // We can't rethrow because Fix match is done in a background thread and Hangfire will requeue multiple times + _logger.LogInformation(ex, "Rate limit hit for matching {SeriesName} with Kavita+", series.Name); + // Fire SignalR event about this + await _eventHub.SendMessageAsync(MessageFactory.ExternalMatchRateLimitError, + MessageFactory.ExternalMatchRateLimitErrorEvent(series.Id, series.Name)); + } + } + + /// + /// Sets a series to Don't Match and removes all previously cached + /// + /// + public async Task UpdateSeriesDontMatch(int seriesId, bool dontMatch) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.ExternalMetadata); + if (series == null) return; + + _logger.LogInformation("User has asked Kavita to stop matching/scrobbling on {SeriesName}", series.Name); + + series.DontMatch = dontMatch; + + if (dontMatch) + { + // When we set as DontMatch, we will clear existing External Metadata + var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(series.ExternalSeriesMetadata); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations); + } + + _unitOfWork.SeriesRepository.Update(series); + + await _unitOfWork.CommitAsync(); + } + + /// + /// Requests the full SeriesDetail (rec, review, metadata) data for a Series. Will save to ExternalMetadata tables. + /// + /// + /// + /// + /// + private async Task FetchExternalMetadataForSeries(int seriesId, LibraryType libraryType, PlusSeriesRequestDto data) + { + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null) + { + return _defaultReturn; + } + + try + { + _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName); + SeriesDetailPlusApiDto? result = null; + + try + { + // This returns an AniListSeries and Match returns ExternalSeriesDto + result = await _kavitaPlusApiService.GetSeriesDetail(data); + } + catch (FlurlHttpException ex) + { + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + + if (ex.StatusCode == 400) + { + if (errorMessage.Contains("Too many Requests")) + { + _logger.LogDebug("Hit rate limit, will retry in 3 seconds"); + await Task.Delay(3000); + + result = await _kavitaPlusApiService.GetSeriesDetail(data); + } + else if (errorMessage.Contains("Unknown Series")) + { + series.IsBlacklisted = true; + await _unitOfWork.CommitAsync(); + } + } + } + + if (result == null) + { + _logger.LogInformation("Hit rate limit twice, try again later"); + return _defaultReturn; + } // Clear out existing results - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - var externalSeriesMetadata = await GetExternalSeriesMetadataForSeries(seriesId, series!); + var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations); @@ -303,12 +494,13 @@ public class ExternalMetadataService : IExternalMetadataService { var rating = _mapper.Map(r); rating.SeriesId = externalSeriesMetadata.SeriesId; + rating.ProviderUrl = r.ProviderUrl; return rating; }).ToList(); // Recommendations - externalSeriesMetadata.ExternalRecommendations ??= new List(); + externalSeriesMetadata.ExternalRecommendations ??= []; var recs = await ProcessRecommendations(libraryType, result.Recommendations, externalSeriesMetadata); var extRatings = externalSeriesMetadata.ExternalRatings @@ -321,35 +513,1268 @@ public class ExternalMetadataService : IExternalMetadataService if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value; if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value; - await _unitOfWork.CommitAsync(); + if (result.CbrId.HasValue) externalSeriesMetadata.CbrId = result.CbrId.Value; + + // If there is metadata and the user has metadata download turned on + var madeMetadataModification = false; + if (result.Series != null && series.Library.AllowMetadataMatching) + { + externalSeriesMetadata.Series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + + try + { + madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId); + if (madeMetadataModification) + { + _unitOfWork.SeriesRepository.Update(series); + _unitOfWork.SeriesRepository.Update(series.Metadata); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to write Series metadata from Kavita+"); + } + + } + + // WriteExternalMetadataToSeries will commit but not always + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + if (madeMetadataModification) + { + // Inform the UI of the update + await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(series.LibraryId, series.Id, series.Name), false); + } return new SeriesDetailPlusDto() { Recommendations = recs, Ratings = result.Ratings, - Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map(r)) + Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map(r)), + Series = result.Series }; } catch (FlurlHttpException ex) { + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + if (ex.StatusCode == 500) { return _defaultReturn; } + + if (ex.StatusCode == 400 && errorMessage.Contains("Too many Requests")) + { + throw new KavitaException("Too many requests, slow down"); + } } catch (Exception ex) { - _logger.LogError(ex, "An error happened during the request to Kavita+ API"); + if (ex.Message.Contains("Too Many Requests")) + { + throw new KavitaException("Too many requests, slow down"); + } + + _logger.LogError(ex, "Unable to fetch external series metadata from Kavita+"); } // Blacklist the series as it wasn't found in Kavita+ - await _unitOfWork.ExternalSeriesMetadataRepository.CreateBlacklistedSeries(seriesId); + series.IsBlacklisted = true; + await _unitOfWork.CommitAsync(); return _defaultReturn; } + /// + /// Given external metadata from Kavita+, write as much as possible to the Kavita series as possible + /// + /// + /// + /// + public async Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId) + { + var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + if (!settings.Enabled) return false; - private async Task GetExternalSeriesMetadataForSeries(int seriesId, Series series) + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Related); + if (series == null) return false; + + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(); + + _logger.LogInformation("Writing External metadata to Series {SeriesName}", series.Name); + + var madeModification = false; + var processedGenres = new List(); + var processedTags = new List(); + + madeModification = UpdateSummary(series, settings, externalMetadata) || madeModification; + madeModification = UpdateReleaseYear(series, settings, externalMetadata) || madeModification; + madeModification = UpdateLocalizedName(series, settings, externalMetadata) || madeModification; + madeModification = await UpdatePublicationStatus(series, settings, externalMetadata) || madeModification; + + // Apply field mappings + GenerateGenreAndTagLists(externalMetadata, settings, ref processedTags, ref processedGenres); + + madeModification = await UpdateGenres(series, settings, externalMetadata, processedGenres) || madeModification; + madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification; + madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification; + + var staff = await SetNameAndAddAliases(settings, externalMetadata.Staff); + + madeModification = await UpdateWriters(series, settings, staff) || madeModification; + madeModification = await UpdateArtists(series, settings, staff) || madeModification; + madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification; + + madeModification = await UpdateRelationships(series, settings, externalMetadata.Relations, defaultAdmin) || madeModification; + madeModification = await UpdateCoverImage(series, settings, externalMetadata) || madeModification; + + madeModification = await UpdateChapters(series, settings, externalMetadata) || madeModification; + + return madeModification; + } + + private async Task> SetNameAndAddAliases(MetadataSettingsDto settings, IList? staff) + { + if (staff == null || staff.Count == 0) return []; + + var nameMappings = staff.Select(s => new + { + Staff = s, + PreferredName = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}", + AlternativeName = !settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}" + }).ToList(); + + var preferredNames = nameMappings.Select(n => n.PreferredName.ToNormalized()).Distinct().ToList(); + var alternativeNames = nameMappings.Select(n => n.AlternativeName.ToNormalized()).Distinct().ToList(); + + var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(preferredNames.Union(alternativeNames).ToList()); + var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople); + + var modified = false; + foreach (var mapping in nameMappings) + { + mapping.Staff.Name = mapping.PreferredName; + + if (existingPeopleDictionary.ContainsKey(mapping.PreferredName.ToNormalized())) + { + continue; + } + + + if (existingPeopleDictionary.TryGetValue(mapping.AlternativeName.ToNormalized(), out var person)) + { + modified = true; + person.Aliases.Add(new PersonAliasBuilder(mapping.PreferredName).Build()); + } + } + + if (modified) + { + await _unitOfWork.CommitAsync(); + } + + return [.. staff]; + } + + private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings, + ref List processedTags, ref List processedGenres) + { + externalMetadata.Tags ??= []; + externalMetadata.Genres ??= []; + + var mappings = ApplyFieldMappings(externalMetadata.Tags.Select(t => t.Name), MetadataFieldType.Tag, settings.FieldMappings); + if (mappings.TryGetValue(MetadataFieldType.Tag, out var tagsToTags)) + { + processedTags.AddRange(tagsToTags); + } + if (mappings.TryGetValue(MetadataFieldType.Genre, out var tagsToGenres)) + { + processedGenres.AddRange(tagsToGenres); + } + + mappings = ApplyFieldMappings(externalMetadata.Genres, MetadataFieldType.Genre, settings.FieldMappings); + if (mappings.TryGetValue(MetadataFieldType.Tag, out var genresToTags)) + { + processedTags.AddRange(genresToTags); + } + if (mappings.TryGetValue(MetadataFieldType.Genre, out var genresToGenres)) + { + processedGenres.AddRange(genresToGenres); + } + + processedTags = ApplyBlackWhiteList(settings, MetadataFieldType.Tag, processedTags); + processedGenres = ApplyBlackWhiteList(settings, MetadataFieldType.Genre, processedGenres); + } + + private async Task UpdateRelationships(Series series, MetadataSettingsDto settings, IList? externalMetadataRelations, AppUser defaultAdmin) + { + if (!settings.EnableRelationships) return false; + + if (externalMetadataRelations == null || externalMetadataRelations.Count == 0 || defaultAdmin == null) + { + return false; + } + + foreach (var relation in externalMetadataRelations.Where(r => r.Relation != RelationKind.Parent)) + { + List names = new [] {relation.SeriesName.PreferredTitle, relation.SeriesName.RomajiTitle, relation.SeriesName.EnglishTitle, relation.SeriesName.NativeTitle}.Where(s => !string.IsNullOrEmpty(s)).ToList()!; + var relatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName( + names, + relation.PlusMediaFormat.GetMangaFormats(), + defaultAdmin.Id, + relation.AniListId, + SeriesIncludes.Related); + + // Skip if no related series found or series is the parent + if (relatedSeries == null || relatedSeries.Id == series.Id || relation.Relation == RelationKind.Parent) continue; + + // Check if the relationship already exists + var relationshipExists = series.Relations.Any(r => + r.TargetSeriesId == relatedSeries.Id && r.RelationKind == relation.Relation); + + if (relationshipExists) continue; + + // Add new relationship + var newRelation = new SeriesRelation + { + RelationKind = relation.Relation, + TargetSeriesId = relatedSeries.Id, + SeriesId = series.Id, + }; + series.Relations.Add(newRelation); + + // Handle sequel/prequel: add reverse relationship + if (relation.Relation is RelationKind.Prequel or RelationKind.Sequel) + { + var reverseExists = relatedSeries.Relations.Any(r => + r.TargetSeriesId == series.Id && r.RelationKind == GetReverseRelation(relation.Relation)); + + if (!reverseExists) + { + var reverseRelation = new SeriesRelation + { + RelationKind = GetReverseRelation(relation.Relation), + TargetSeriesId = series.Id, + SeriesId = relatedSeries.Id, + }; + relatedSeries.Relations.Add(reverseRelation); + _unitOfWork.SeriesRepository.Attach(reverseRelation); + } + } + + _unitOfWork.SeriesRepository.Update(series); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + return true; + } + + private async Task UpdateCharacters(Series series, MetadataSettingsDto settings, IList? externalCharacters) + { + if (!settings.EnablePeople) return false; + + if (externalCharacters == null || externalCharacters.Count == 0) return false; + + if (series.Metadata.CharacterLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.Character)) + { + return false; + } + + series.Metadata.People ??= []; + + var characters = externalCharacters + .Select(w => new PersonDto() + { + Name = w.Name.Trim(), + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Character) + // Need to ensure existing people are retained, but we overwrite anything from a bad match + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + if (characters.Count == 0) return false; + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); + + foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) + { + // Set a sort order based on their role + var characterMeta = externalCharacters.FirstOrDefault(c => c.Name == spPerson.Person.Name); + spPerson.OrderWeight = 0; + + if (characterMeta != null) + { + spPerson.KavitaPlusConnection = true; + + spPerson.OrderWeight = characterMeta.Role switch + { + CharacterRole.Main => 0, + CharacterRole.Supporting => 1, + CharacterRole.Background => 2, + _ => 99 // Default for unknown roles + }; + } + } + + // Download the image and save it + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + foreach (var character in externalCharacters) + { + var aniListId = ScrobblingService.ExtractId(character.Url, ScrobblingService.AniListCharacterWebsite); + if (aniListId <= 0) continue; + var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId); + if (person != null && !string.IsNullOrEmpty(character.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) + { + await _coverDbService.SetPersonCoverByUrl(person, character.ImageUrl, false); + } + } + + series.Metadata.AddKPlusOverride(MetadataSettingField.People); + return true; + } + + private async Task UpdateArtists(Series series, MetadataSettingsDto settings, List staff) + { + if (!settings.EnablePeople) return false; + + + var upstreamArtists = staff + .Where(s => s.Role is "Art" or "Story & Art") + .ToList(); + + if (upstreamArtists.Count == 0) return false; + + if (series.Metadata.CoverArtistLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.CoverArtist)) + { + return false; + } + + series.Metadata.People ??= []; + var artists = upstreamArtists + .Select(w => new PersonDto() + { + Name = w.Name.Trim(), + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.CoverArtist) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork); + + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist)) + { + var meta = upstreamArtists.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetPersonCovers(upstreamArtists); + + series.Metadata.AddKPlusOverride(MetadataSettingField.People); + return true; + } + + private async Task UpdateWriters(Series series, MetadataSettingsDto settings, List staff) + { + if (!settings.EnablePeople) return false; + + var upstreamWriters = staff + .Where(s => s.Role is "Story" or "Story & Art") + .ToList(); + + if (upstreamWriters.Count == 0) return false; + + if (series.Metadata.WriterLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.Writer)) + { + return false; + } + + series.Metadata.People ??= []; + var writers = upstreamWriters + .Select(w => new PersonDto() + { + Name = w.Name.Trim(), + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Writer) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork); + + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.Writer)) + { + var meta = upstreamWriters.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetPersonCovers(upstreamWriters); + series.Metadata.AddKPlusOverride(MetadataSettingField.People); + return true; + } + + private async Task UpdateTags(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedTags) + { + externalMetadata.Tags ??= []; + + if (!settings.EnableTags || processedTags.Count == 0) return false; + + if (series.Metadata.TagsLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Tags)) + { + return false; + } + + _logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name); + var madeModification = false; + var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize))) + .ToList(); + series.Metadata.Tags ??= []; + + TagHelper.UpdateTagList(processedTags, series, allTags, tag => + { + series.Metadata.Tags.Add(tag); + madeModification = true; + }, () => series.Metadata.TagsLocked = true); + + if (madeModification) + { + series.Metadata.AddKPlusOverride(MetadataSettingField.Tags); + } + + return madeModification; + } + + private static List ApplyBlackWhiteList(MetadataSettingsDto settings, MetadataFieldType fieldType, List processedStrings) + { + return fieldType switch + { + MetadataFieldType.Genre => processedStrings.Distinct() + .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) + .ToList(), + MetadataFieldType.Tag => processedStrings.Distinct() + .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) + .Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g)) + .ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null) + }; + } + + private async Task UpdateGenres(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedGenres) + { + externalMetadata.Genres ??= []; + + if (!settings.EnableGenres || processedGenres.Count == 0) return false; + + if (series.Metadata.GenresLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Genres)) + { + return false; + } + + _logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name); + var madeModification = false; + var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); + series.Metadata.Genres ??= []; + var exisitingGenres = series.Metadata.Genres; + + GenreHelper.UpdateGenreList(processedGenres, series, allGenres, genre => + { + series.Metadata.Genres.Add(genre); + madeModification = true; + }, () => series.Metadata.GenresLocked = true); + + foreach (var genre in exisitingGenres) + { + if (series.Metadata.Genres.FirstOrDefault(g => g.NormalizedTitle == genre.NormalizedTitle) != null) continue; + series.Metadata.Genres.Add(genre); + madeModification = true; + } + + if (madeModification) + { + series.Metadata.AddKPlusOverride(MetadataSettingField.Genres); + } + + return madeModification; + } + + private async Task UpdatePublicationStatus(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnablePublicationStatus) return false; + + if (series.Metadata.PublicationStatusLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.PublicationStatus)) + { + return false; + } + + try + { + var chapters = + (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes + .SelectMany(v => v.Chapters).ToList(); + var status = DeterminePublicationStatus(series, chapters, externalMetadata); + + series.Metadata.PublicationStatus = status; + series.Metadata.PublicationStatusLocked = true; + series.Metadata.AddKPlusOverride(MetadataSettingField.PublicationStatus); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue determining Publication Status for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + + return false; + } + + private bool UpdateAgeRating(Series series, MetadataSettingsDto settings, IEnumerable allExternalTags) + { + + if (series.Metadata.AgeRatingLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.AgeRating)) + { + return false; + } + + try + { + // Determine Age Rating + var totalTags = allExternalTags + .Concat(series.Metadata.Genres.Select(g => g.Title)) + .Concat(series.Metadata.Tags.Select(g => g.Title)); + + var ageRating = DetermineAgeRating(totalTags, settings.AgeRatingMappings); + if (series.Metadata.AgeRating <= ageRating) + { + series.Metadata.AgeRating = ageRating; + series.Metadata.AddKPlusOverride(MetadataSettingField.AgeRating); + return true; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue determining Age Rating for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + + return false; + } + + + private async Task UpdateChapters(Series series, MetadataSettingsDto settings, + ExternalSeriesDetailDto externalMetadata) + { + if (externalMetadata.PlusMediaFormat != PlusMediaFormat.Comic) return false; + + if (externalMetadata.ChapterDtos == null || externalMetadata.ChapterDtos.Count == 0) return false; + + // Get all volumes and chapters + var madeModification = false; + var allChapters = await _unitOfWork.ChapterRepository.GetAllChaptersForSeries(series.Id); + + var matchedChapters = allChapters + .Join( + externalMetadata.ChapterDtos, + chapter => chapter.Range, + dto => dto.IssueNumber, + (chapter, dto) => (chapter, dto) // Create a tuple of matched pairs + ) + .ToList(); + + foreach (var (chapter, potentialMatch) in matchedChapters) + { + _logger.LogDebug("Updating {ChapterNumber} with metadata", chapter.Range); + + // Write the metadata + madeModification = UpdateChapterTitle(chapter, settings, potentialMatch.Title, series.Name) || madeModification; + madeModification = UpdateChapterSummary(chapter, settings, potentialMatch.Summary) || madeModification; + madeModification = UpdateChapterReleaseDate(chapter, settings, potentialMatch.ReleaseDate) || madeModification; + + var hasUpdatedPublisher = await UpdateChapterPublisher(chapter, settings, potentialMatch.Publisher); + if (hasUpdatedPublisher) chapter.AddKPlusOverride(MetadataSettingField.ChapterPublisher); + madeModification = hasUpdatedPublisher || madeModification; + + madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.CoverArtist, potentialMatch.Artists) || madeModification; + madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification; + + madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification; + madeModification = await UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification; + + _unitOfWork.ChapterRepository.Update(chapter); + await _unitOfWork.CommitAsync(); + } + + return madeModification; + } + + private async Task UpdateExternalChapterMetadata(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata) + { + if (!settings.Enabled) return false; + + if (metadata.UserReviews.Count == 0 && metadata.CriticReviews.Count == 0) + { + return false; + } + + var madeModification = false; + + #region Review + + // Remove existing Reviews + var existingReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReview(chapter.Id); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(existingReviews); + + + List externalReviews = []; + externalReviews.AddRange(metadata.CriticReviews + .Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body)) + .Select(r => + { + var review = _mapper.Map(r); + review.ChapterId = chapter.Id; + review.Authority = RatingAuthority.Critic; + CleanCbrReview(ref review); + return review; + })); + externalReviews.AddRange(metadata.UserReviews + .Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body)) + .Select(r => + { + var review = _mapper.Map(r); + review.ChapterId = chapter.Id; + review.Authority = RatingAuthority.User; + CleanCbrReview(ref review); + return review; + })); + + chapter.ExternalReviews = externalReviews; + madeModification = externalReviews.Count > 0; + _logger.LogDebug("Added {Count} reviews for chapter {ChapterId}", externalReviews.Count, chapter.Id); + #endregion + + #region Rating + + // C# can't make the implicit conversation here + float? averageCriticRating = metadata.CriticReviews.Count > 0 ? metadata.CriticReviews.Average(r => r.Rating) : null; + float? averageUserRating = metadata.UserReviews.Count > 0 ? metadata.UserReviews.Average(r => r.Rating) : null; + + var existingRatings = await _unitOfWork.ChapterRepository.GetExternalChapterRatings(chapter.Id); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(existingRatings); + + chapter.ExternalRatings = []; + + if (averageUserRating != null) + { + chapter.ExternalRatings.Add(new ExternalRating + { + AverageScore = (int) averageUserRating, + Provider = ScrobbleProvider.Cbr, + Authority = RatingAuthority.User, + ProviderUrl = metadata.IssueUrl, + + }); + chapter.AverageExternalRating = averageUserRating.Value; + } + + if (averageCriticRating != null) + { + chapter.ExternalRatings.Add(new ExternalRating + { + AverageScore = (int) averageCriticRating, + Provider = ScrobbleProvider.Cbr, + Authority = RatingAuthority.Critic, + ProviderUrl = metadata.IssueUrl, + + }); + } + + madeModification = averageUserRating > 0f || averageCriticRating > 0f || madeModification; + + #endregion + + return madeModification; + } + + private static void CleanCbrReview(ref ExternalReview review) + { + // CBR has Read Full Review which links to site, but we already have that + review.Body = review.Body.Replace("Read Full Review", string.Empty).TrimEnd(); + review.RawBody = review.RawBody.Replace("Read Full Review", string.Empty).TrimEnd(); + review.BodyJustText = review.BodyJustText.Replace("Read Full Review", string.Empty).TrimEnd(); + } + + + private static bool UpdateChapterSummary(Chapter chapter, MetadataSettingsDto settings, string? summary) + { + if (!settings.EnableChapterSummary) return false; + + if (string.IsNullOrEmpty(summary)) return false; + + if (chapter.SummaryLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterSummary)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(summary) && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterSummary)) + { + return false; + } + + chapter.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(summary)); + chapter.AddKPlusOverride(MetadataSettingField.ChapterSummary); + return true; + } + + private static bool UpdateChapterTitle(Chapter chapter, MetadataSettingsDto settings, string? title, string seriesName) + { + if (!settings.EnableChapterTitle) return false; + + if (string.IsNullOrEmpty(title)) return false; + + if (chapter.TitleNameLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterTitle)) + { + return false; + } + + if (!title.Contains(seriesName) && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterTitle)) + { + return false; + } + + chapter.TitleName = title; + chapter.AddKPlusOverride(MetadataSettingField.ChapterTitle); + return true; + } + + private static bool UpdateChapterReleaseDate(Chapter chapter, MetadataSettingsDto settings, DateTime? releaseDate) + { + if (!settings.EnableChapterReleaseDate) return false; + + if (releaseDate == null || releaseDate == DateTime.MinValue) return false; + + if (chapter.ReleaseDateLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterReleaseDate)) + { + return false; + } + + if (!HasForceOverride(settings, chapter, MetadataSettingField.ChapterReleaseDate)) + { + return false; + } + + chapter.ReleaseDate = releaseDate.Value; + chapter.AddKPlusOverride(MetadataSettingField.ChapterReleaseDate); + return true; + } + + private async Task UpdateChapterPublisher(Chapter chapter, MetadataSettingsDto settings, string? publisher) + { + if (!settings.EnableChapterPublisher) return false; + + if (string.IsNullOrEmpty(publisher)) return false; + + if (chapter.PublisherLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterPublisher)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(publisher) && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterPublisher)) + { + return false; + } + + // Some publishers (CBR) can be represented as Boom! Studios/Boom! Town imprint, so let's handle that appropriately + if (publisher.Contains('/') || publisher.Contains("imprint", StringComparison.InvariantCultureIgnoreCase)) + { + var imprint = publisher.Split('/')[1].Replace("imprint", string.Empty); + return await UpdateChapterPeople(chapter, settings, PersonRole.Publisher, [publisher]) || + await UpdateChapterPeople(chapter, settings, PersonRole.Imprint, [imprint]); + } + + return await UpdateChapterPeople(chapter, settings, PersonRole.Publisher, [publisher]); + } + + private async Task UpdateChapterCoverImage(Chapter chapter, MetadataSettingsDto settings, string? coverUrl) + { + if (!settings.EnableChapterCoverImage) return false; + + if (string.IsNullOrEmpty(coverUrl)) return false; + + if (chapter.CoverImageLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterCovers)) + { + return false; + } + + if (string.IsNullOrEmpty(coverUrl)) + { + return false; + } + + await DownloadChapterCovers(chapter, coverUrl); + chapter.AddKPlusOverride(MetadataSettingField.ChapterCovers); + return true; + } + + private async Task UpdateChapterPeople(Chapter chapter, MetadataSettingsDto settings, PersonRole role, IList? staff) + { + if (!settings.EnablePeople) return false; + + if (staff?.Count == 0) return false; + + if (chapter.IsPersonRoleLocked(role) && !HasForceOverride(settings, chapter, MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(role) && role != PersonRole.Publisher) + { + return false; + } + + chapter.People ??= []; + var people = staff! + .Select(w => new PersonDto() + { + Name = w.Trim(), + }) + .Concat(chapter.People + .Where(p => p.Role == role) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + await PersonHelper.UpdateChapterPeopleAsync(chapter, staff ?? [], role, _unitOfWork); + + foreach (var person in chapter.People.Where(p => p.Role == role)) + { + var meta = people.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.ChapterRepository.Update(chapter); + await _unitOfWork.CommitAsync(); + + return true; + } + + private async Task UpdateCoverImage(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableCoverImage) return false; + + if (string.IsNullOrEmpty(externalMetadata.CoverUrl)) return false; + + if (series.CoverImageLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Covers)) + { + return false; + } + + if (string.IsNullOrEmpty(externalMetadata.CoverUrl)) + { + return false; + } + + await DownloadSeriesCovers(series, externalMetadata.CoverUrl); + series.Metadata.AddKPlusOverride(MetadataSettingField.Covers); + return true; + } + + + private static bool UpdateReleaseYear(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableStartDate) return false; + + if (!externalMetadata.StartDate.HasValue) return false; + + if (series.Metadata.ReleaseYearLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.StartDate)) + { + return false; + } + + if (series.Metadata.ReleaseYear != 0 && !HasForceOverride(settings, series.Metadata, MetadataSettingField.StartDate)) + { + return false; + } + + series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; + series.Metadata.AddKPlusOverride(MetadataSettingField.StartDate); + return true; + } + + private static bool UpdateLocalizedName(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableLocalizedName) return false; + + if (series.LocalizedNameLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.LocalizedName)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(series.LocalizedName) && !HasForceOverride(settings, series.Metadata, MetadataSettingField.LocalizedName)) + { + return false; + } + + // We need to make the best appropriate guess + if (externalMetadata.Name == series.Name) + { + // Choose closest (usually last) synonym + var validSynonyms = externalMetadata.Synonyms + .Where(IsRomanCharacters) + .Where(s => s.ToNormalized() != series.Name.ToNormalized()) + .ToList(); + + if (validSynonyms.Count == 0) return false; + + series.LocalizedName = validSynonyms[^1]; + series.LocalizedNameLocked = true; + } + else if (IsRomanCharacters(externalMetadata.Name)) + { + series.LocalizedName = externalMetadata.Name; + series.LocalizedNameLocked = true; + } + + + series.Metadata.AddKPlusOverride(MetadataSettingField.LocalizedName); + return true; + } + + private static bool UpdateSummary(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableSummary) return false; + + if (string.IsNullOrEmpty(externalMetadata.Summary)) return false; + + if (series.Metadata.SummaryLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Summary)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(series.Metadata.Summary) && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Summary)) + { + return false; + } + + series.Metadata.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(externalMetadata.Summary)); + series.Metadata.AddKPlusOverride(MetadataSettingField.Summary); + return true; + } + + + private static RelationKind GetReverseRelation(RelationKind relation) + { + return relation switch + { + RelationKind.Prequel => RelationKind.Sequel, + RelationKind.Sequel => RelationKind.Prequel, + _ => relation // For other relationships, no reverse needed + }; + } + + private async Task DownloadSeriesCovers(Series series, string coverUrl) + { + try + { + // Only choose the better image if we're overriding a user provided cover + await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false, !series.Metadata.HasSetKPlusMetadata(MetadataSettingField.Covers)); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception downloading cover image for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + } + + private async Task DownloadChapterCovers(Chapter chapter, string coverUrl) + { + try + { + await _coverDbService.SetChapterCoverByUrl(chapter, coverUrl, false, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception downloading cover image for Chapter {ChapterName} ({SeriesId})", chapter.Range, chapter.Id); + } + } + + private async Task DownloadAndSetPersonCovers(List people) + { + foreach (var staff in people) + { + var aniListId = ScrobblingService.ExtractId(staff.Url, ScrobblingService.AniListStaffWebsite); + if (aniListId is null or <= 0) continue; + var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value); + if (person == null || string.IsNullOrEmpty(staff.ImageUrl) || + !string.IsNullOrEmpty(person.CoverImage) || staff.ImageUrl.EndsWith("default.jpg")) continue; + + try + { + await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception saving cover image for Person {PersonName} ({PersonId})", person.Name, person.Id); + } + + } + } + + private PublicationStatus DeterminePublicationStatus(Series series, List chapters, ExternalSeriesDetailDto externalMetadata) + { + try + { + // Determine the expected total count based on local metadata + series.Metadata.TotalCount = Math.Max( + chapters.Max(chapter => chapter.TotalCount), + externalMetadata.Volumes > 0 ? externalMetadata.Volumes : externalMetadata.Chapters + ); + + // The actual number of count's defined across all chapter's metadata + series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count); + + var nonSpecialVolumes = series.Volumes + .Where(v => v.MaxNumber.IsNot(Parser.SpecialVolumeNumber)) + .ToList(); + + var maxVolume = (int)(nonSpecialVolumes.Count != 0 ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); + var maxChapter = (int)chapters.Max(c => c.MaxNumber); + + if (series.Format is MangaFormat.Epub or MangaFormat.Pdf && chapters.Count == 1) + { + series.Metadata.MaxCount = 1; + } + else if (series.Metadata.TotalCount <= 1 && chapters is [{ IsSpecial: true }]) + { + series.Metadata.MaxCount = series.Metadata.TotalCount; + } + else if ((maxChapter == Parser.DefaultChapterNumber || maxChapter > series.Metadata.TotalCount) && + maxVolume <= series.Metadata.TotalCount && maxVolume != Parser.DefaultChapterNumber) + { + series.Metadata.MaxCount = maxVolume; + } + else if (maxVolume == series.Metadata.TotalCount) + { + series.Metadata.MaxCount = maxVolume; + } + else + { + series.Metadata.MaxCount = maxChapter; + } + + var status = PublicationStatus.OnGoing; + + var hasExternalCounts = externalMetadata.Volumes > 0 || externalMetadata.Chapters > 0; + + if (hasExternalCounts) + { + status = PublicationStatus.Ended; + + if (IsSeriesCompleted(series, chapters, externalMetadata, maxVolume)) + { + status = PublicationStatus.Completed; + } + } + + return status; + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an issue determining Publication Status"); + } + + return PublicationStatus.OnGoing; + } + + /// + /// Returns true if the series should be marked as completed, checks loosey with chapter and series numbers. + /// Respects Specials to reach the required amount. + /// + /// + /// + /// + /// + /// + /// Updates MaxCount and TotalCount if a loosey check is used to set as completed + public static bool IsSeriesCompleted(Series series, List chapters, ExternalSeriesDetailDto externalMetadata, int maxVolumes) + { + // A series is completed if exactly the amount is found + if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + { + return true; + } + + // If volumes are collected, check if we reach the required volumes by including specials, and decimal volumes + // + // TODO BUG: If the series has specials, that are not included in the external count. But you do own them + // This may mark the series as completed pre-maturely + // Note: I've currently opted to keep this an equals to prevent the above bug from happening + // We *could* change this to >= in the future in case this is reported by users + // If we do; test IsSeriesCompleted_Volumes_TooManySpecials needs to be updated + if (maxVolumes != Parser.DefaultChapterNumber && externalMetadata.Volumes == series.Volumes.Count) + { + series.Metadata.MaxCount = series.Volumes.Count; + series.Metadata.TotalCount = series.Volumes.Count; + return true; + } + + // Note: If Kavita has specials, we should be lenient and ignore for the volume check + var volumeModifier = series.Volumes.Any(v => v.Name == Parser.SpecialVolume) ? 1 : 0; + var modifiedMinVolumeCount = series.Volumes.Count - volumeModifier; + if (maxVolumes != Parser.DefaultChapterNumber && externalMetadata.Volumes == modifiedMinVolumeCount) + { + series.Metadata.MaxCount = modifiedMinVolumeCount; + series.Metadata.TotalCount = modifiedMinVolumeCount; + return true; + } + + // If no volumes are collected, the series is completed if we reach or exceed the external chapters + if (maxVolumes == Parser.DefaultChapterNumber && series.Metadata.MaxCount >= externalMetadata.Chapters) + { + series.Metadata.TotalCount = series.Metadata.MaxCount; + return true; + } + + // If no volumes are collected, the series is complete if we reach or exceed the external chapters while including + // prologues, and extra chapters + if (maxVolumes == Parser.DefaultChapterNumber && chapters.Count >= externalMetadata.Chapters) + { + series.Metadata.TotalCount = chapters.Count; + series.Metadata.MaxCount = chapters.Count; + return true; + } + + + return false; + } + + private static Dictionary> ApplyFieldMappings(IEnumerable values, MetadataFieldType sourceType, List mappings) + { + var result = new Dictionary>(); + + foreach (var field in Enum.GetValues()) + { + result[field] = []; + } + + foreach (var value in values) + { + var mapping = mappings.FirstOrDefault(m => + m.SourceType == sourceType && + m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase)); + + if (mapping != null && !string.IsNullOrWhiteSpace(mapping.DestinationValue)) + { + var targetType = mapping.DestinationType; + + if (!mapping.ExcludeFromSource) + { + result[sourceType].Add(mapping.SourceValue); + } + + result[targetType].Add(mapping.DestinationValue); + } + else + { + // If no mapping, keep the original value + result[sourceType].Add(value); + } + } + + // Ensure distinct + foreach (var key in result.Keys) + { + result[key] = result[key].Distinct().ToList(); + } + + return result; + } + + + /// + /// Returns the highest age rating from all tags/genres based on user-supplied mappings + /// + /// A combo of all tags/genres + /// + /// + public static AgeRating DetermineAgeRating(IEnumerable values, Dictionary mappings) + { + // Find highest age rating from mappings + mappings ??= new Dictionary(); + + return values + .Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown) + .DefaultIfEmpty(AgeRating.Unknown) + .Max(); + } + + + /// + /// Gets from DB or creates a new one with just SeriesId + /// + /// + /// + /// + private async Task GetOrCreateExternalSeriesMetadataForSeries(int seriesId, Series series) { var externalSeriesMetadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId); if (externalSeriesMetadata != null) return externalSeriesMetadata; @@ -360,6 +1785,7 @@ public class ExternalMetadataService : IExternalMetadataService }; series.ExternalSeriesMetadata = externalSeriesMetadata; _unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata); + return externalSeriesMetadata; } @@ -428,7 +1854,16 @@ public class ExternalMetadataService : IExternalMetadataService } - private async Task GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId) + /// + /// This is to get series information for the recommendation drawer on Kavita + /// + /// This uses a different API that series detail + /// + /// + /// + /// + /// + private async Task GetSeriesDetail(int? aniListId, long? malId, int? seriesId) { var payload = new ExternalMetadataIdsDto() { @@ -454,22 +1889,17 @@ public class ExternalMetadataService : IExternalMetadataService } payload.SeriesName = series.Name; payload.LocalizedSeriesName = series.LocalizedName; - payload.PlusMediaFormat = ConvertToMediaFormat(series.Library.Type, series.Format); + payload.PlusMediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format); } } try { - return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(payload) - .ReceiveJson(); + var ret = await _kavitaPlusApiService.GetSeriesDetailById(payload); + + ret.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(ret.Summary)); + + return ret; } catch (Exception e) @@ -480,15 +1910,9 @@ public class ExternalMetadataService : IExternalMetadataService return null; } - private static MediaFormat ConvertToMediaFormat(LibraryType libraryType, MangaFormat seriesFormat) + private static bool HasForceOverride(MetadataSettingsDto settings, IHasKPlusMetadata kPlusMetadata, + MetadataSettingField field) { - return libraryType switch - { - LibraryType.Manga => seriesFormat == MangaFormat.Epub ? MediaFormat.LightNovel : MediaFormat.Manga, - LibraryType.Comic => MediaFormat.Comic, - LibraryType.Book => MediaFormat.Book, - LibraryType.LightNovel => MediaFormat.LightNovel, - _ => MediaFormat.Unknown - }; + return settings.HasOverride(field) || kPlusMetadata.HasSetKPlusMetadata(field); } } diff --git a/API/Services/Plus/KavitaPlusApiService.cs b/API/Services/Plus/KavitaPlusApiService.cs new file mode 100644 index 000000000..ec4f414c3 --- /dev/null +++ b/API/Services/Plus/KavitaPlusApiService.cs @@ -0,0 +1,126 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Collection; +using API.DTOs.KavitaPlus.ExternalMetadata; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Metadata.Matching; +using API.DTOs.Scrobbling; +using API.Entities.Enums; +using API.Extensions; +using Flurl.Http; +using Kavita.Common; +using Microsoft.Extensions.Logging; + +namespace API.Services.Plus; + +/// +/// All Http requests to K+ should be contained in this service, the service will not handle any errors. +/// This is expected from the caller. +/// +public interface IKavitaPlusApiService +{ + Task HasTokenExpired(string license, string token, ScrobbleProvider provider); + Task GetRateLimit(string license, string token); + Task PostScrobbleUpdate(ScrobbleDto data, string license); + Task> GetMalStacks(string malUsername, string license); + Task> MatchSeries(MatchSeriesRequestDto request); + Task GetSeriesDetail(PlusSeriesRequestDto request); + Task GetSeriesDetailById(ExternalMetadataIdsDto request); +} + +public class KavitaPlusApiService(ILogger logger, IUnitOfWork unitOfWork): IKavitaPlusApiService +{ + private const string ScrobblingPath = "/api/scrobbling/"; + + public async Task HasTokenExpired(string license, string token, ScrobbleProvider provider) + { + var res = await Get(ScrobblingPath + "valid-key?provider=" + provider + "&key=" + token, license, token); + var str = await res.GetStringAsync(); + return bool.Parse(str); + } + + public async Task GetRateLimit(string license, string token) + { + var res = await Get(ScrobblingPath + "rate-limit?accessToken=" + token, license, token); + var str = await res.GetStringAsync(); + return int.Parse(str); + } + + public async Task PostScrobbleUpdate(ScrobbleDto data, string license) + { + return await PostAndReceive(ScrobblingPath + "update", data, license); + } + + public async Task> GetMalStacks(string malUsername, string license) + { + return await $"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={malUsername}" + .WithKavitaPlusHeaders(license) + .GetJsonAsync>(); + } + + public async Task> MatchSeries(MatchSeriesRequestDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson>(); + } + + public async Task GetSeriesDetail(PlusSeriesRequestDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson(); + } + + public async Task GetSeriesDetailById(ExternalMetadataIdsDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson(); + } + + /// + /// Send a GET request to K+ + /// + /// only path of the uri, the host is added + /// + /// + /// + private static async Task Get(string url, string license, string? aniListToken = null) + { + return await (Configuration.KavitaPlusApiUrl + url) + .WithKavitaPlusHeaders(license, aniListToken) + .GetAsync(); + } + + /// + /// Send a POST request to K+ + /// + /// only path of the uri, the host is added + /// + /// + /// + /// Return type + /// + private static async Task PostAndReceive(string url, object body, string license, string? aniListToken = null) + { + return await (Configuration.KavitaPlusApiUrl + url) + .WithKavitaPlusHeaders(license, aniListToken) + .PostJsonAsync(body) + .ReceiveJson(); + } +} diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index 5a12f7c0b..91f5a8fdd 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -1,10 +1,13 @@ using System; +using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; using API.DTOs.Account; -using API.DTOs.License; +using API.DTOs.KavitaPlus.License; using API.Entities.Enums; +using API.Extensions; +using API.Services.Tasks; using EasyCaching.Core; using Flurl.Http; using Kavita.Common; @@ -29,17 +32,23 @@ public interface ILicenseService Task HasActiveLicense(bool forceCheck = false); Task HasActiveSubscription(string? license); Task ResetLicense(string license, string email); + Task GetLicenseInfo(bool forceCheck = false); } public class LicenseService( IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork, - ILogger logger) + ILogger logger, + IVersionUpdaterService versionUpdaterService) : ILicenseService { private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8); - public const string Cron = "0 */4 * * *"; - private const string CacheKey = "license"; + public const string Cron = "0 */9 * * *"; + /// + /// Cache key for if license is valid or not + /// + public const string CacheKey = "license"; + private const string LicenseInfoCacheKey = "license-info"; /// @@ -53,13 +62,7 @@ public class LicenseService( try { var response = await (Configuration.KavitaPlusApiUrl + "/api/license/check") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(new LicenseValidDto() { License = license, @@ -87,13 +90,7 @@ public class LicenseService( try { var response = await (Configuration.KavitaPlusApiUrl + "/api/license/register") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(new EncryptLicenseDto() { License = license.Trim(), @@ -118,36 +115,6 @@ public class LicenseService( } } - /// - /// Checks licenses and updates cache - /// - /// Expected to be called at startup and on reoccurring basis - // public async Task ValidateLicenseStatus() - // { - // var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); - // try - // { - // var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - // if (string.IsNullOrEmpty(license.Value)) { - // await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); - // return; - // } - // - // _logger.LogInformation("Validating Kavita+ License"); - // - // await provider.FlushAsync(); - // var isValid = await IsLicenseValid(license.Value); - // await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout); - // - // _logger.LogInformation("Validating Kavita+ License - Complete"); - // } - // catch (Exception ex) - // { - // _logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins"); - // await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); - // BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30)); - // } - // } /// /// Checks licenses and updates cache @@ -163,22 +130,23 @@ public class LicenseService( if (cacheValue.HasValue) return cacheValue.Value; } + var result = false; try { var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - var result = await IsLicenseValid(serverSetting.Value); - await provider.FlushAsync(); - await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); - return result; + result = await IsLicenseValid(serverSetting.Value); } catch (Exception ex) { logger.LogError(ex, "There was an issue connecting to Kavita+"); + } + finally + { await provider.FlushAsync(); - await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); + await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); } - return false; + return result; } /// @@ -192,13 +160,7 @@ public class LicenseService( try { var response = await (Configuration.KavitaPlusApiUrl + "/api/license/check-sub") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(new LicenseValidDto() { License = license, @@ -230,6 +192,8 @@ public class LicenseService( var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); await provider.RemoveAsync(CacheKey); + + } public async Task AddLicense(string license, string email, string? discordId) @@ -251,13 +215,7 @@ public class LicenseService( { var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var response = await (Configuration.KavitaPlusApiUrl + "/api/license/reset") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", encryptedLicense.Value) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(encryptedLicense.Value) .PostJsonAsync(new ResetLicenseDto() { License = license.Trim(), @@ -283,4 +241,68 @@ public class LicenseService( return false; } + + /// + /// Fetches information about the license from Kavita+. If there is no license or an exception, will return null and can be assumed it is not active + /// + /// + /// + public async Task GetLicenseInfo(bool forceCheck = false) + { + // Check if there is a license + var hasLicense = + !string.IsNullOrEmpty((await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)) + .Value); + + if (!hasLicense) return null; + + // Check the cache + var licenseInfoProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.LicenseInfo); + if (!forceCheck) + { + var cacheValue = await licenseInfoProvider.GetAsync(LicenseInfoCacheKey); + if (cacheValue.HasValue) return cacheValue.Value; + } + + // TODO: If info.IsCancelled && notActive, let's remove the license so we aren't constantly checking + + try + { + var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); + var response = await (Configuration.KavitaPlusApiUrl + "/api/license/info") + .WithKavitaPlusHeaders(encryptedLicense.Value) + .GetJsonAsync(); + + // This indicates a mismatch on installId or no active subscription + if (response == null) return null; + + // Ensure that current version is within the 3 version limit. Don't count Nightly releases or Hotfixes + var releases = await versionUpdaterService.GetAllReleases(); + response.IsValidVersion = releases + .Where(r => !r.UpdateTitle.Contains("Hotfix")) // We don't care about Hotfix releases + .Where(r => !r.IsPrerelease) // Ensure we don't take current nightlies within the current/last stable + .Take(3) + .All(r => new Version(r.UpdateVersion) <= BuildInfo.Version); + + response.HasLicense = hasLicense; + + // Cache if the license is valid here as well + var licenseProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + await licenseProvider.SetAsync(CacheKey, response.IsActive, _licenseCacheTimeout); + + // Cache the license info if IsActive and ExpirationDate > DateTime.UtcNow + 2 + if (response.IsActive && response.ExpirationDate > DateTime.UtcNow.AddDays(2)) + { + await licenseInfoProvider.SetAsync(LicenseInfoCacheKey, response, _licenseCacheTimeout); + } + + return response; + } + catch (FlurlHttpException e) + { + logger.LogError(e, "An error happened during the request to Kavita+ API"); + } + + return null; + } } diff --git a/API/Services/Plus/RecommendationService.cs b/API/Services/Plus/RecommendationService.cs deleted file mode 100644 index 24cb1445b..000000000 --- a/API/Services/Plus/RecommendationService.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using API.Data; -using API.Data.Repositories; -using API.DTOs; -using API.DTOs.Recommendation; -using API.DTOs.Scrobbling; -using API.Entities; -using API.Entities.Enums; -using API.Extensions; -using API.Helpers.Builders; -using Flurl.Http; -using Kavita.Common; -using Kavita.Common.EnvironmentInfo; -using Kavita.Common.Helpers; -using Microsoft.Extensions.Logging; - -namespace API.Services.Plus; -#nullable enable - - -public interface IRecommendationService -{ - //Task GetRecommendationsForSeries(int userId, int seriesId); -} - - -public class RecommendationService : IRecommendationService -{ - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - - public RecommendationService(IUnitOfWork unitOfWork, ILogger logger) - { - _unitOfWork = unitOfWork; - _logger = logger; - - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - } - - public async Task GetRecommendationsForSeries(int userId, int seriesId) - { - var series = - await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, - SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters); - if (series == null || series.Library.Type == LibraryType.Comic) return new RecommendationDto(); - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var canSeeExternalSeries = user is {AgeRestriction: AgeRating.NotApplicable} && - await _unitOfWork.UserRepository.IsUserAdminAsync(user); - - var recDto = new RecommendationDto() - { - ExternalSeries = new List(), - OwnedSeries = new List() - }; - - var recs = await GetRecommendations(license.Value, series); - foreach (var rec in recs) - { - // Find the series based on name and type and that the user has access too - var seriesForRec = await _unitOfWork.SeriesRepository.GetSeriesDtoByNamesAndMetadataIds(rec.RecommendationNames, - series.Library.Type, ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, rec.AniListId), - ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, rec.MalId)); - - if (seriesForRec != null) - { - recDto.OwnedSeries.Add(seriesForRec); - continue; - } - - if (!canSeeExternalSeries) continue; - // We can show this based on user permissions - if (string.IsNullOrEmpty(rec.Name) || string.IsNullOrEmpty(rec.SiteUrl) || string.IsNullOrEmpty(rec.CoverUrl)) continue; - recDto.ExternalSeries.Add(new ExternalSeriesDto() - { - Name = string.IsNullOrEmpty(rec.Name) ? rec.RecommendationNames.First() : rec.Name, - Url = rec.SiteUrl, - CoverUrl = rec.CoverUrl, - Summary = rec.Summary, - AniListId = rec.AniListId, - MalId = rec.MalId - }); - } - - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, recDto.OwnedSeries); - - recDto.OwnedSeries = recDto.OwnedSeries.DistinctBy(s => s.Id).OrderBy(r => r.Name).ToList(); - recDto.ExternalSeries = recDto.ExternalSeries.DistinctBy(s => s.Name.ToNormalized()).OrderBy(r => r.Name).ToList(); - - return recDto; - } - - - protected async Task> GetRecommendations(string license, Series series) - { - try - { - return await (Configuration.KavitaPlusApiUrl + "/api/recommendation") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(new PlusSeriesDtoBuilder(series).Build()) - .ReceiveJson>(); - - } - catch (Exception e) - { - _logger.LogError(e, "An error happened during the request to Kavita+ API"); - } - - return new List(); - } -} diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 5dba6f56e..f9c3fdb09 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -10,6 +11,7 @@ using API.DTOs.Filtering; using API.DTOs.Scrobbling; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Entities.Scrobble; using API.Extensions; using API.Helpers; @@ -18,8 +20,8 @@ using API.SignalR; using Flurl.Http; using Hangfire; using Kavita.Common; -using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Plus; @@ -36,27 +38,124 @@ public enum ScrobbleProvider Kavita = 0, AniList = 1, Mal = 2, + [Obsolete("No longer supported")] + GoogleBooks = 3, + Cbr = 4 } public interface IScrobblingService { + /// + /// An automated job that will run against all user's tokens and validate if they are still active + /// + /// This service can validate without license check as the task which calls will be guarded + /// Task CheckExternalAccessTokens(); + + /// + /// Checks if the token has expired with , if it has double checks with K+, + /// otherwise return false. + /// + /// + /// + /// + /// Returns true if there is no license present Task HasTokenExpired(int userId, ScrobbleProvider provider); + /// + /// Create, or update a non-processed, event, for the given series + /// + /// + /// + /// + /// Task ScrobbleRatingUpdate(int userId, int seriesId, float rating); + /// + /// NOP, until hardcover support has been worked out + /// + /// + /// + /// + /// + /// Task ScrobbleReviewUpdate(int userId, int seriesId, string? reviewTitle, string reviewBody); + /// + /// Create, or update a non-processed, event, for the given series + /// + /// + /// + /// Task ScrobbleReadingUpdate(int userId, int seriesId); + /// + /// Creates an or for + /// the given series + /// + /// + /// + /// + /// + /// Only the result of both WantToRead types is send to K+ Task ScrobbleWantToReadUpdate(int userId, int seriesId, bool onWantToRead); + /// + /// Removed all processed events that are at least 7 days old + /// + /// [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public Task ClearProcessedEvents(); + + /// + /// Makes K+ requests for all non-processed events until rate limits are reached + /// + /// [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ProcessUpdatesSinceLastSync(); + Task CreateEventsFromExistingHistory(int userId = 0); + Task CreateEventsFromExistingHistoryForSeries(int seriesId); Task ClearEventsForSeries(int userId, int seriesId); } +/// +/// Context used when syncing scrobble events. Do NOT reuse between syncs +/// +public class ScrobbleSyncContext +{ + public required List ReadEvents {get; init;} + public required List RatingEvents {get; init;} + /// Do not use this as events to send to K+, use + public required List AddToWantToRead {get; init;} + /// Do not use this as events to send to K+, use + public required List RemoveWantToRead {get; init;} + /// + /// Final events list if all AddTo- and RemoveWantToRead would be processed sequentially + /// + public required List Decisions {get; init;} + /// + /// K+ license + /// + public required string License { get; init; } + /// + /// Maps userId to left over request amount + /// + public required Dictionary RateLimits { get; init; } + + /// + /// All users being scrobbled for + /// + public List Users { get; set; } = []; + /// + /// Amount of already processed events + /// + public int ProgressCounter { get; set; } + + /// + /// Sum of all events to process + /// + public int TotalCount => ReadEvents.Count + RatingEvents.Count + AddToWantToRead.Count + RemoveWantToRead.Count; +} + public class ScrobblingService : IScrobblingService { private readonly IUnitOfWork _unitOfWork; @@ -64,53 +163,62 @@ public class ScrobblingService : IScrobblingService private readonly ILogger _logger; private readonly ILicenseService _licenseService; private readonly ILocalizationService _localizationService; + private readonly IEmailService _emailService; + private readonly IKavitaPlusApiService _kavitaPlusApiService; public const string AniListWeblinkWebsite = "https://anilist.co/manga/"; public const string MalWeblinkWebsite = "https://myanimelist.net/manga/"; public const string GoogleBooksWeblinkWebsite = "https://books.google.com/books?id="; public const string MangaDexWeblinkWebsite = "https://mangadex.org/title/"; + public const string AniListStaffWebsite = "https://anilist.co/staff/"; + public const string AniListCharacterWebsite = "https://anilist.co/character/"; - private static readonly IDictionary WeblinkExtractionMap = new Dictionary() + + private static readonly Dictionary WeblinkExtractionMap = new() { {AniListWeblinkWebsite, 0}, {MalWeblinkWebsite, 0}, {GoogleBooksWeblinkWebsite, 0}, {MangaDexWeblinkWebsite, 0}, + {AniListStaffWebsite, 0}, + {AniListCharacterWebsite, 0}, }; private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90) - private static readonly IList BookProviders = new List() - { - }; - private static readonly IList LightNovelProviders = new List() - { + private static readonly IList BookProviders = []; + private static readonly IList LightNovelProviders = + [ ScrobbleProvider.AniList - }; - private static readonly IList ComicProviders = new List(); - private static readonly IList MangaProviders = new List() - { - ScrobbleProvider.AniList - }; + ]; + private static readonly IList ComicProviders = []; + private static readonly IList MangaProviders = (List) + [ScrobbleProvider.AniList]; private const string UnknownSeriesErrorMessage = "Series cannot be matched for Scrobbling"; private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling"; + private const string InvalidKPlusLicenseErrorMessage = "Kavita+ subscription no longer active"; + private const string ReviewFailedErrorMessage = "Review was unable to be saved due to upstream requirements"; + private const string BadPayLoadErrorMessage = "Bad payload from Scrobble Provider"; public ScrobblingService(IUnitOfWork unitOfWork, IEventHub eventHub, ILogger logger, - ILicenseService licenseService, ILocalizationService localizationService) + ILicenseService licenseService, ILocalizationService localizationService, IEmailService emailService, + IKavitaPlusApiService kavitaPlusApiService) { _unitOfWork = unitOfWork; _eventHub = eventHub; _logger = logger; _licenseService = licenseService; _localizationService = localizationService; + _emailService = emailService; + _kavitaPlusApiService = kavitaPlusApiService; - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } + #region Access token checks /// /// An automated job that will run against all user's tokens and validate if they are still active @@ -123,13 +231,71 @@ public class ScrobblingService : IScrobblingService var users = await _unitOfWork.UserRepository.GetAllUsersAsync(); foreach (var user in users) { - if (string.IsNullOrEmpty(user.AniListAccessToken) || !TokenService.HasTokenExpired(user.AniListAccessToken)) continue; - _logger.LogInformation("User {UserName}'s AniList token has expired! They need to regenerate it for scrobbling to work", user.UserName); - await _eventHub.SendMessageToAsync(MessageFactory.ScrobblingKeyExpired, - MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), user.Id); + if (string.IsNullOrEmpty(user.AniListAccessToken)) continue; + + var tokenExpiry = JwtHelper.GetTokenExpiry(user.AniListAccessToken); + + // Send early reminder 5 days before token expiry + if (await ShouldSendEarlyReminder(user.Id, tokenExpiry)) + { + await _emailService.SendTokenExpiringSoonEmail(user.Id, ScrobbleProvider.AniList); + } + + // Send expiration notification after token expiry + if (await ShouldSendExpirationReminder(user.Id, tokenExpiry)) + { + await _emailService.SendTokenExpiredEmail(user.Id, ScrobbleProvider.AniList); + } + + // Check token validity + if (JwtHelper.IsTokenValid(user.AniListAccessToken)) continue; + + _logger.LogInformation( + "User {UserName}'s AniList token has expired or is expiring in a few days! They need to regenerate it for scrobbling to work", + user.UserName); + + // Notify user via event + await _eventHub.SendMessageToAsync( + MessageFactory.ScrobblingKeyExpired, + MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), + user.Id); + } } + /// + /// Checks if an early reminder email should be sent. + /// + private async Task ShouldSendEarlyReminder(int userId, DateTime tokenExpiry) + { + var earlyReminderDate = tokenExpiry.AddDays(-5); + if (earlyReminderDate > DateTime.UtcNow) return false; + + var hasAlreadySentReminder = await _unitOfWork.DataContext.EmailHistory + .AnyAsync(h => h.AppUserId == userId && h.Sent && + h.EmailTemplate == EmailService.TokenExpiringSoonTemplate && + h.SendDate >= earlyReminderDate); + + return !hasAlreadySentReminder; + + } + + /// + /// Checks if an expiration notification email should be sent. + /// + private async Task ShouldSendExpirationReminder(int userId, DateTime tokenExpiry) + { + if (tokenExpiry > DateTime.UtcNow) return false; + + var hasAlreadySentExpirationEmail = await _unitOfWork.DataContext.EmailHistory + .AnyAsync(h => h.AppUserId == userId && h.Sent && + h.EmailTemplate == EmailService.TokenExpirationTemplate && + h.SendDate >= tokenExpiry); + + return !hasAlreadySentExpirationEmail; + + } + public async Task HasTokenExpired(int userId, ScrobbleProvider provider) { var token = await GetTokenForProvider(userId, provider); @@ -147,25 +313,14 @@ public class ScrobblingService : IScrobblingService private async Task HasTokenExpired(string token, ScrobbleProvider provider) { - if (string.IsNullOrEmpty(token) || - !TokenService.HasTokenExpired(token)) return false; + if (string.IsNullOrEmpty(token) || !TokenService.HasTokenExpired(token)) return false; var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); if (string.IsNullOrEmpty(license.Value)) return true; try { - var response = await (Configuration.KavitaPlusApiUrl + "/api/scrobbling/valid-key?provider=" + provider + "&key=" + token) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license.Value) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .GetStringAsync(); - - return bool.Parse(response); + return await _kavitaPlusApiService.HasTokenExpired(license.Value, token, provider); } catch (HttpRequestException e) { @@ -191,59 +346,14 @@ public class ScrobblingService : IScrobblingService } ?? string.Empty; } - public async Task ScrobbleReviewUpdate(int userId, int seriesId, string? reviewTitle, string reviewBody) + #endregion + + #region Scrobble ingest + + public Task ScrobbleReviewUpdate(int userId, int seriesId, string? reviewTitle, string reviewBody) { // Currently disabled until at least hardcover is implemented - return; - if (!await _licenseService.HasActiveLicense()) return; - - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); - if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - - _logger.LogInformation("Processing Scrobbling review event for {UserId} on {SeriesName}", userId, series.Name); - if (await CheckIfCannotScrobble(userId, seriesId, series)) return; - - if (IsAniListReviewValid(reviewTitle, reviewBody)) - { - _logger.LogDebug( - "Rejecting Scrobble event for {Series}. Review is not long enough to meet requirements", series.Name); - return; - } - - var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, - ScrobbleEventType.Review); - if (existingEvt is {IsProcessed: false}) - { - _logger.LogDebug("Overriding Review scrobble event for {Series}", existingEvt.Series.Name); - existingEvt.ReviewBody = reviewBody; - existingEvt.ReviewTitle = reviewTitle; - _unitOfWork.ScrobbleRepository.Update(existingEvt); - await _unitOfWork.CommitAsync(); - return; - } - - var evt = new ScrobbleEvent() - { - SeriesId = series.Id, - LibraryId = series.LibraryId, - ScrobbleEventType = ScrobbleEventType.Review, - AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = GetMalId(series), - AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), - ReviewBody = reviewBody, - ReviewTitle = reviewTitle - }; - _unitOfWork.ScrobbleRepository.Attach(evt); - await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Review update on {SeriesName} with Userid {UserId} ", series.Name, userId); - } - - private static bool IsAniListReviewValid(string reviewTitle, string reviewBody) - { - return string.IsNullOrEmpty(reviewTitle) || string.IsNullOrEmpty(reviewBody) || (reviewTitle.Length < 2200 || - reviewTitle.Length > 120 || - reviewTitle.Length < 20); + return Task.CompletedTask; } public async Task ScrobbleRatingUpdate(int userId, int seriesId, float rating) @@ -253,11 +363,14 @@ public class ScrobblingService : IScrobblingService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - _logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + + _logger.LogInformation("Processing Scrobbling rating event for {AppUserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, - ScrobbleEventType.ScoreUpdated); + ScrobbleEventType.ScoreUpdated, true); if (existingEvt is {IsProcessed: false}) { // We need to just update Volume/Chapter number @@ -277,24 +390,12 @@ public class ScrobblingService : IScrobblingService AniListId = GetAniListId(series), MalId = GetMalId(series), AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), Rating = rating }; _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId}", series.Name, userId); - } - - private static long? GetMalId(Series series) - { - var malId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite); - return malId ?? series.ExternalSeriesMetadata.MalId; - } - - private static int? GetAniListId(Series series) - { - var aniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite); - return aniListId ?? series.ExternalSeriesMetadata.AniListId; + _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {AppUserId}", series.Name, userId); } public async Task ScrobbleReadingUpdate(int userId, int seriesId) @@ -304,31 +405,55 @@ public class ScrobblingService : IScrobblingService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - _logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + + _logger.LogInformation("Processing Scrobbling reading event for {AppUserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; + var isAnyProgressOnSeries = await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId); + + var volumeNumber = (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId); + var chapterNumber = await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId); + + // Check if there is an existing not yet processed event, if so update it var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, - ScrobbleEventType.ChapterRead); + ScrobbleEventType.ChapterRead, true); + if (existingEvt is {IsProcessed: false}) { + if (!isAnyProgressOnSeries) + { + _unitOfWork.ScrobbleRepository.Remove(existingEvt); + await _unitOfWork.CommitAsync(); + _logger.LogDebug("Removed scrobble event for {Series} as there is no reading progress", series.Name); + return; + } + // We need to just update Volume/Chapter number var prevChapter = $"{existingEvt.ChapterNumber}"; var prevVol = $"{existingEvt.VolumeNumber}"; - existingEvt.VolumeNumber = - (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId); - existingEvt.ChapterNumber = - await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId); + existingEvt.VolumeNumber = volumeNumber; + existingEvt.ChapterNumber = chapterNumber; + _unitOfWork.ScrobbleRepository.Update(existingEvt); await _unitOfWork.CommitAsync(); + _logger.LogDebug("Overriding scrobble event for {Series} from vol {PrevVol} ch {PrevChap} -> vol {UpdatedVol} ch {UpdatedChap}", existingEvt.Series.Name, prevVol, prevChapter, existingEvt.VolumeNumber, existingEvt.ChapterNumber); return; } + if (!isAnyProgressOnSeries) + { + // Do not create a new scrobble event if there is no progress + return; + } + try { - var evt = new ScrobbleEvent() + var evt = new ScrobbleEvent { SeriesId = series.Id, LibraryId = series.LibraryId, @@ -336,16 +461,20 @@ public class ScrobblingService : IScrobblingService AniListId = GetAniListId(series), MalId = GetMalId(series), AppUserId = userId, - VolumeNumber = - (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId), - ChapterNumber = - await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId), - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + VolumeNumber = volumeNumber, + ChapterNumber = chapterNumber, + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), }; + if (evt.VolumeNumber is Parser.SpecialVolumeNumber) + { + // We don't process Specials because they will never match on AniList + return; + } + _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Read update on {SeriesName} - Volume: {VolumeNumber} Chapter: {ChapterNumber} for User: {UserId}", series.Name, evt.VolumeNumber, evt.ChapterNumber, userId); + _logger.LogDebug("Added Scrobbling Read update on {SeriesName} - Volume: {VolumeNumber} Chapter: {ChapterNumber} for User: {AppUserId}", series.Name, evt.VolumeNumber, evt.ChapterNumber, userId); } catch (Exception ex) { @@ -360,13 +489,22 @@ public class ScrobblingService : IScrobblingService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); + if (!series.Library.AllowScrobbling) return; + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + if (await CheckIfCannotScrobble(userId, seriesId, series)) return; + _logger.LogInformation("Processing Scrobbling want-to-read event for {AppUserId} on {SeriesName}", userId, series.Name); - var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id, - onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead); - if (existing) return; // BUG: If I take a series and add to remove from want to read, then add to want to read, Kavita rejects the second as a duplicate, when it's not + // Get existing events for this series/user + var existingEvents = (await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId)) + .Where(e => new[] { ScrobbleEventType.AddWantToRead, ScrobbleEventType.RemoveWantToRead }.Contains(e.ScrobbleEventType)); + // Remove all existing want-to-read events for this series/user + _unitOfWork.ScrobbleRepository.Remove(existingEvents); + + // Create the new event var evt = new ScrobbleEvent() { SeriesId = series.Id, @@ -375,552 +513,35 @@ public class ScrobblingService : IScrobblingService AniListId = GetAniListId(series), MalId = GetMalId(series), AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), }; + _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId); + _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {AppUserId} ", series.Name, userId); } - private async Task CheckIfCannotScrobble(int userId, int seriesId, Series series) - { - if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) - { - _logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, - userId); - return true; - } + #endregion - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); - if (library is not {AllowScrobbling: true}) return true; - if (!ExternalMetadataService.IsPlusEligible(library.Type)) return true; - return false; + #region Scrobble provider methods + + private static bool IsAniListReviewValid(string reviewTitle, string reviewBody) + { + return string.IsNullOrEmpty(reviewTitle) || string.IsNullOrEmpty(reviewBody) || (reviewTitle.Length < 2200 || + reviewTitle.Length > 120 || + reviewTitle.Length < 20); } - private async Task GetRateLimit(string license, string aniListToken) + public static long? GetMalId(Series series) { - if (string.IsNullOrWhiteSpace(aniListToken)) return 0; - try - { - var response = await (Configuration.KavitaPlusApiUrl + "/api/scrobbling/rate-limit?accessToken=" + aniListToken) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .GetStringAsync(); - - return int.Parse(response); - } - catch (Exception e) - { - _logger.LogError(e, "An error happened during the request to Kavita+ API"); - } - - return 0; + var malId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite); + return malId ?? series.ExternalSeriesMetadata?.MalId; } - private async Task PostScrobbleUpdate(ScrobbleDto data, string license, ScrobbleEvent evt) + public static int? GetAniListId(Series seriesWithExternalMetadata) { - try - { - var response = await (Configuration.KavitaPlusApiUrl + "/api/scrobbling/update") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(data) - .ReceiveJson(); - - if (!response.Successful) - { - // Might want to log this under ScrobbleError - if (response.ErrorMessage != null && response.ErrorMessage.Contains("Too Many Requests")) - { - _logger.LogInformation("Hit Too many requests, sleeping to regain requests and retrying"); - await Task.Delay(TimeSpan.FromMinutes(10)); - return await PostScrobbleUpdate(data, license, evt); - } - if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unauthorized")) - { - _logger.LogCritical("Kavita+ responded with Unauthorized. Please check your subscription"); - await _licenseService.HasActiveLicense(true); - evt.IsErrored = true; - evt.ErrorDetails = "Kavita+ subscription no longer active"; - throw new KavitaException("Kavita+ responded with Unauthorized. Please check your subscription"); - } - if (response.ErrorMessage != null && response.ErrorMessage.Contains("Access token is invalid")) - { - evt.IsErrored = true; - evt.ErrorDetails = AccessTokenErrorMessage; - throw new KavitaException("Access token is invalid"); - } - if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series")) - { - // Log the Series name and Id in ScrobbleErrors - _logger.LogInformation("Kavita+ was unable to match the series"); - if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = UnknownSeriesErrorMessage, - Details = data.SeriesName, - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - await _unitOfWork.ExternalSeriesMetadataRepository.CreateBlacklistedSeries(evt.SeriesId, false); - } - - evt.IsErrored = true; - evt.ErrorDetails = UnknownSeriesErrorMessage; - } else if (response.ErrorMessage != null && response.ErrorMessage.StartsWith("Review")) - { - // Log the Series name and Id in ScrobbleErrors - _logger.LogInformation("Kavita+ was unable to save the review"); - if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = response.ErrorMessage, - Details = data.SeriesName, - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - } - evt.IsErrored = true; - evt.ErrorDetails = "Review was unable to be saved due to upstream requirements"; - } - } - - return response.RateLeft; - } - catch (FlurlHttpException ex) - { - _logger.LogError("Scrobbling to Kavita+ API failed due to error: {ErrorMessage}", ex.Message); - if (ex.Message.Contains("Call failed with status code 500 (Internal Server Error)")) - { - if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = UnknownSeriesErrorMessage, - Details = data.SeriesName, - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - } - evt.IsErrored = true; - evt.ErrorDetails = "Bad payload from Scrobble Provider"; - throw new KavitaException("Bad payload from Scrobble Provider"); - } - throw; - } - } - - /// - /// This will back fill events from existing progress history, ratings, and want to read for users that have a valid license - /// - /// Defaults to 0 meaning all users. Allows a userId to be set if a scrobble key is added to a user - public async Task CreateEventsFromExistingHistory(int userId = 0) - { - var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) - .ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling); - - var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()) - .Where(l => userId == 0 || userId == l.Id) - .Select(u => u.Id); - - if (!await _licenseService.HasActiveLicense()) return; - - foreach (var uId in userIds) - { - var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId); - foreach (var wtr in wantToRead) - { - if (!libAllowsScrobbling[wtr.LibraryId]) continue; - await ScrobbleWantToReadUpdate(uId, wtr.Id, true); - } - - var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(uId); - foreach (var rating in ratings) - { - if (!libAllowsScrobbling[rating.Series.LibraryId]) continue; - await ScrobbleRatingUpdate(uId, rating.SeriesId, rating.Rating); - } - - var reviews = await _unitOfWork.UserRepository.GetSeriesWithReviews(uId); - foreach (var review in reviews) - { - if (!libAllowsScrobbling[review.Series.LibraryId]) continue; - await ScrobbleReviewUpdate(uId, review.SeriesId, review.Tagline, review.Review); - } - - var seriesWithProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, uId, - new UserParams(), new FilterDto() - { - ReadStatus = new ReadStatus() - { - Read = true, - InProgress = true, - NotRead = false - }, - Libraries = libAllowsScrobbling.Keys.Where(k => libAllowsScrobbling[k]).ToList() - }); - - foreach (var series in seriesWithProgress) - { - if (!libAllowsScrobbling[series.LibraryId]) continue; - if (series.PagesRead <= 0) continue; // Since we only scrobble when things are higher, we can - await ScrobbleReadingUpdate(uId, series.Id); - } - - } - } - - /// - /// Removes all events (active) that are tied to a now-on hold series - /// - /// - /// - public async Task ClearEventsForSeries(int userId, int seriesId) - { - _logger.LogInformation("Clearing Pre-existing Scrobble events for Series {SeriesId} by User {UserId} as Series is now on hold list", seriesId, userId); - var events = await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId); - foreach (var scrobble in events) - { - _unitOfWork.ScrobbleRepository.Remove(scrobble); - } - - await _unitOfWork.CommitAsync(); - } - - /// - /// Removes all events that have been processed that are 7 days old - /// - [DisableConcurrentExecution(60 * 60 * 60)] - [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ClearProcessedEvents() - { - var events = await _unitOfWork.ScrobbleRepository.GetProcessedEvents(7); - _unitOfWork.ScrobbleRepository.Remove(events); - await _unitOfWork.CommitAsync(); - } - - /// - /// This is a task that is ran on a fixed schedule (every few hours or every day) that clears out the scrobble event table - /// and offloads the data to the API server which performs the syncing to the providers. - /// - [DisableConcurrentExecution(60 * 60 * 60)] - [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ProcessUpdatesSinceLastSync() - { - // Check how many scrobble events we have available then only do those. - _logger.LogInformation("Starting Scrobble Processing"); - var userRateLimits = new Dictionary(); - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - - var progressCounter = 0; - - var librariesWithScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) - .AsEnumerable() - .Where(l => l.AllowScrobbling) - .Select(l => l.Id) - .ToImmutableHashSet(); - - var errors = (await _unitOfWork.ScrobbleRepository.GetScrobbleErrors()) - .Where(e => e.Comment == "Unknown Series" || e.Comment == UnknownSeriesErrorMessage || e.Comment == AccessTokenErrorMessage) - .Select(e => e.SeriesId) - .ToList(); - - var readEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.ChapterRead)) - .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) - .Where(e => !errors.Contains(e.SeriesId)) - .ToList(); - var addToWantToRead = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.AddWantToRead)) - .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) - .Where(e => !errors.Contains(e.SeriesId)) - .ToList(); - var removeWantToRead = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.RemoveWantToRead)) - .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) - .Where(e => !errors.Contains(e.SeriesId)) - .ToList(); - var ratingEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.ScoreUpdated)) - .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) - .Where(e => !errors.Contains(e.SeriesId)) - .ToList(); - - var decisions = addToWantToRead - .GroupBy(item => new { item.SeriesId, item.AppUserId }) - .Select(group => new - { - group.Key.SeriesId, - UserId = group.Key.AppUserId, - Event = group.First(), - Decision = group.Count() - removeWantToRead - .Count(removeItem => removeItem.SeriesId == group.Key.SeriesId && removeItem.AppUserId == group.Key.AppUserId) - }) - .Where(d => d.Decision > 0) - .Select(d => d.Event) - .ToList(); - - // For all userIds, ensure that we can connect and have access - var usersToScrobble = readEvents.Select(r => r.AppUser) - .Concat(addToWantToRead.Select(r => r.AppUser)) - .Concat(removeWantToRead.Select(r => r.AppUser)) - .Concat(ratingEvents.Select(r => r.AppUser)) - .Where(user => !string.IsNullOrEmpty(user.AniListAccessToken)) - .DistinctBy(u => u.Id) - .ToList(); - foreach (var user in usersToScrobble) - { - await SetAndCheckRateLimit(userRateLimits, user, license.Value); - } - - var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count; - - _logger.LogInformation("Found {TotalEvents} Scrobble Events", totalProgress); - try - { - // Recalculate the highest volume/chapter - foreach (var readEvt in readEvents) - { - readEvt.VolumeNumber = - (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(readEvt.SeriesId, - readEvt.AppUser.Id); - readEvt.ChapterNumber = - await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(readEvt.SeriesId, - readEvt.AppUser.Id); - _unitOfWork.ScrobbleRepository.Update(readEvt); - } - progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, async evt => new ScrobbleDto() - { - Format = evt.Format, - AniListId = evt.AniListId, - MALId = (int?) evt.MalId, - ScrobbleEventType = evt.ScrobbleEventType, - ChapterNumber = evt.ChapterNumber, - VolumeNumber = (int?) evt.VolumeNumber, - AniListToken = evt.AppUser.AniListAccessToken, - SeriesName = evt.Series.Name, - LocalizedSeriesName = evt.Series.LocalizedName, - ScrobbleDateUtc = evt.LastModifiedUtc, - Year = evt.Series.Metadata.ReleaseYear, - StartedReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetFirstProgressForSeries(evt.SeriesId, evt.AppUser.Id), - LatestReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(evt.SeriesId, evt.AppUser.Id), - }); - - progressCounter = await ProcessEvents(ratingEvents, userRateLimits, usersToScrobble.Count, progressCounter, - totalProgress, evt => Task.FromResult(new ScrobbleDto() - { - Format = evt.Format, - AniListId = evt.AniListId, - MALId = (int?) evt.MalId, - ScrobbleEventType = evt.ScrobbleEventType, - AniListToken = evt.AppUser.AniListAccessToken, - SeriesName = evt.Series.Name, - LocalizedSeriesName = evt.Series.LocalizedName, - Rating = evt.Rating, - Year = evt.Series.Metadata.ReleaseYear - })); - - progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter, - totalProgress, evt => Task.FromResult(new ScrobbleDto() - { - Format = evt.Format, - AniListId = evt.AniListId, - MALId = (int?) evt.MalId, - ScrobbleEventType = evt.ScrobbleEventType, - ChapterNumber = evt.ChapterNumber, - VolumeNumber = (int?) evt.VolumeNumber, - AniListToken = evt.AppUser.AniListAccessToken, - SeriesName = evt.Series.Name, - LocalizedSeriesName = evt.Series.LocalizedName, - Year = evt.Series.Metadata.ReleaseYear - })); - - // After decisions, we need to mark all the want to read and remove from want to read as completed - if (decisions.All(d => d.IsProcessed)) - { - foreach (var scrobbleEvent in addToWantToRead) - { - scrobbleEvent.IsProcessed = true; - scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); - } - foreach (var scrobbleEvent in removeWantToRead) - { - scrobbleEvent.IsProcessed = true; - scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); - } - await _unitOfWork.CommitAsync(); - } - } - catch (FlurlHttpException) - { - _logger.LogError("Kavita+ API or a Scrobble service may be experiencing an outage. Stopping sending data"); - return; - } - - - await SaveToDb(progressCounter, true); - _logger.LogInformation("Scrobbling Events is complete"); - } - - - private async Task ProcessEvents(IEnumerable events, IDictionary userRateLimits, - int usersToScrobble, int progressCounter, int totalProgress, Func> createEvent) - { - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - foreach (var evt in events) - { - _logger.LogDebug("Processing Reading Events: {Count} / {Total}", progressCounter, totalProgress); - progressCounter++; - // Check if this media item can even be processed for this user - if (!DoesUserHaveProviderAndValid(evt)) - { - continue; - } - - if (TokenService.HasTokenExpired(evt.AppUser.AniListAccessToken)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = "AniList token has expired and needs rotating. Scrobbles wont work until then", - Details = $"User: {evt.AppUser.UserName}", - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - await _unitOfWork.CommitAsync(); - return 0; - } - - if (await _unitOfWork.ExternalSeriesMetadataRepository.IsBlacklistedSeries(evt.SeriesId)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = UnknownSeriesErrorMessage, - Details = $"User: {evt.AppUser.UserName} Series: {evt.Series.Name}", - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - evt.IsErrored = true; - evt.ErrorDetails = UnknownSeriesErrorMessage; - evt.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(evt); - await _unitOfWork.CommitAsync(); - return 0; - } - - var count = await SetAndCheckRateLimit(userRateLimits, evt.AppUser, license.Value); - userRateLimits[evt.AppUserId] = count; - if (count == 0) - { - if (usersToScrobble == 1) break; - continue; - } - - try - { - var data = await createEvent(evt); - // We need to handle the encoding and changing it to the old one until we can update the API layer to handle these - // which could happen in v0.8.3 - if (data.VolumeNumber is Parser.SpecialVolumeNumber) - { - data.VolumeNumber = 0; - } - if (data.VolumeNumber is Parser.DefaultChapterNumber) - { - data.VolumeNumber = 0; - } - if (data.ChapterNumber is Parser.DefaultChapterNumber) - { - data.ChapterNumber = 0; - } - userRateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, license.Value, evt); - evt.IsProcessed = true; - evt.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(evt); - } - catch (FlurlHttpException) - { - // If a flurl exception occured, the API is likely down. Kill processing - throw; - } - catch (KavitaException ex) - { - if (ex.Message.Contains("Access token is invalid")) - { - _logger.LogCritical("Access Token for UserId: {UserId} needs to be rotated to continue scrobbling", evt.AppUser.Id); - evt.IsErrored = true; - evt.ErrorDetails = AccessTokenErrorMessage; - _unitOfWork.ScrobbleRepository.Update(evt); - return progressCounter; - } - } - catch (Exception) - { - /* Swallow as it's already been handled in PostScrobbleUpdate */ - } - await SaveToDb(progressCounter); - // We can use count to determine how long to sleep based on rate gain. It might be specific to AniList, but we can model others - var delay = count > 10 ? TimeSpan.FromMilliseconds(ScrobbleSleepTime) : TimeSpan.FromSeconds(60); - await Task.Delay(delay); - } - - await SaveToDb(progressCounter, true); - return progressCounter; - } - - private async Task SaveToDb(int progressCounter, bool force = false) - { - if (!force || progressCounter % 5 == 0) - { - _logger.LogDebug("Saving Progress"); - await _unitOfWork.CommitAsync(); - } - } - - - private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent) - { - var userProviders = GetUserProviders(readEvent.AppUser); - if (readEvent.Series.Library.Type == LibraryType.Manga && MangaProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.Comic && - ComicProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.Book && - BookProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.LightNovel && - LightNovelProviders.Intersect(userProviders).Any()) - { - return true; - } - - return false; - } - - private static IList GetUserProviders(AppUser appUser) - { - var providers = new List(); - if (!string.IsNullOrEmpty(appUser.AniListAccessToken)) providers.Add(ScrobbleProvider.AniList); - return providers; + var aniListId = ExtractId(seriesWithExternalMetadata.Metadata.WebLinks, AniListWeblinkWebsite); + return aniListId ?? seriesWithExternalMetadata.ExternalSeriesMetadata?.AniListId; } /// @@ -935,17 +556,23 @@ public class ScrobblingService : IScrobblingService foreach (var webLink in webLinks.Split(',')) { if (!webLink.StartsWith(website)) continue; + var tokens = webLink.Split(website)[1].Split('/'); var value = tokens[index]; + if (typeof(T) == typeof(int?)) { - if (int.TryParse(value, out var intValue)) - return (T)(object)intValue; + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) return (T)(object)intValue; + } + else if (typeof(T) == typeof(int)) + { + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) return (T)(object)intValue; + + return default; } else if (typeof(T) == typeof(long?)) { - if (long.TryParse(value, out var longValue)) - return (T)(object)longValue; + if (long.TryParse(value, CultureInfo.InvariantCulture, out var longValue)) return (T)(object)longValue; } else if (typeof(T) == typeof(string)) { @@ -953,7 +580,813 @@ public class ScrobblingService : IScrobblingService } } - return default(T?); + return default; + } + + /// + /// Generate a URL from a given ID and website + /// + /// Type of the ID (e.g., int, long, string) + /// The ID to embed in the URL + /// The base website URL + /// The generated URL or null if the website is not supported + public static string? GenerateUrl(T id, string website) + { + if (!WeblinkExtractionMap.ContainsKey(website)) + { + return null; // Unsupported website + } + + if (Equals(id, default(T))) + { + throw new ArgumentNullException(nameof(id), "ID cannot be null."); + } + + // Ensure the type of the ID matches supported types + if (typeof(T) == typeof(int) || typeof(T) == typeof(long) || typeof(T) == typeof(string)) + { + return $"{website}{id}"; + } + + throw new ArgumentException("Unsupported ID type. Supported types are int, long, and string.", nameof(id)); + } + + public static string CreateUrl(string url, long? id) + { + return id is null or 0 ? string.Empty : $"{url}{id}/"; + } + + #endregion + + /// + /// Returns false if, the series is on hold or Don't Match, or when the library has scrobbling disable or not eligible + /// + /// + /// + /// + /// + private async Task CheckIfCannotScrobble(int userId, int seriesId, Series series) + { + if (series.DontMatch) return true; + + if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) + { + _logger.LogInformation("Series {SeriesName} is on AppUserId {AppUserId}'s hold list. Not scrobbling", series.Name, userId); + return true; + } + + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); + if (library is not {AllowScrobbling: true} || !ExternalMetadataService.IsPlusEligible(library.Type)) return true; + + return false; + } + + /// + /// Returns the rate limit from the K+ api + /// + /// + /// + /// + private async Task GetRateLimit(string license, string aniListToken) + { + if (string.IsNullOrWhiteSpace(aniListToken)) return 0; + + try + { + return await _kavitaPlusApiService.GetRateLimit(license, aniListToken); + } + catch (Exception e) + { + _logger.LogError(e, "An error happened trying to get rate limit from Kavita+ API"); + } + + return 0; + } + + #region Scrobble process (Requests to K+) + + /// + /// Retrieve all events for which the series has not errored, then delete all current errors + /// + private async Task PrepareScrobbleContext() + { + var librariesWithScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) + .AsEnumerable() + .Where(l => l.AllowScrobbling) + .Select(l => l.Id) + .ToImmutableHashSet(); + + var erroredSeries = (await _unitOfWork.ScrobbleRepository.GetScrobbleErrors()) + .Where(e => e.Comment is "Unknown Series" or UnknownSeriesErrorMessage or AccessTokenErrorMessage) + .Select(e => e.SeriesId) + .ToList(); + + var readEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.ChapterRead)) + .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) + .Where(e => !erroredSeries.Contains(e.SeriesId)) + .ToList(); + var addToWantToRead = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.AddWantToRead)) + .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) + .Where(e => !erroredSeries.Contains(e.SeriesId)) + .ToList(); + var removeWantToRead = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.RemoveWantToRead)) + .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) + .Where(e => !erroredSeries.Contains(e.SeriesId)) + .ToList(); + var ratingEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.ScoreUpdated)) + .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) + .Where(e => !erroredSeries.Contains(e.SeriesId)) + .ToList(); + + return new ScrobbleSyncContext + { + ReadEvents = readEvents, + RatingEvents = ratingEvents, + AddToWantToRead = addToWantToRead, + RemoveWantToRead = removeWantToRead, + Decisions = CalculateNetWantToReadDecisions(addToWantToRead, removeWantToRead), + RateLimits = [], + License = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value, + }; + } + + /// + /// Filters users who can scrobble, sets their rate limit and updates the + /// + /// + /// + private async Task PrepareUsersToScrobble(ScrobbleSyncContext ctx) + { + // For all userIds, ensure that we can connect and have access + var usersToScrobble = ctx.ReadEvents.Select(r => r.AppUser) + .Concat(ctx.AddToWantToRead.Select(r => r.AppUser)) + .Concat(ctx.RemoveWantToRead.Select(r => r.AppUser)) + .Concat(ctx.RatingEvents.Select(r => r.AppUser)) + .Where(user => !string.IsNullOrEmpty(user.AniListAccessToken)) + .Where(user => user.UserPreferences.AniListScrobblingEnabled) + .DistinctBy(u => u.Id) + .ToList(); + + foreach (var user in usersToScrobble) + { + await SetAndCheckRateLimit(ctx.RateLimits, user, ctx.License); + } + + ctx.Users = usersToScrobble; + } + + /// + /// Cleans up any events that are due to bugs or legacy + /// + private async Task CleanupOldOrBuggedEvents() + { + try + { + var eventsWithoutAnilistToken = (await _unitOfWork.ScrobbleRepository.GetEvents()) + .Where(e => e is { IsProcessed: false, IsErrored: false }) + .Where(e => string.IsNullOrEmpty(e.AppUser.AniListAccessToken)); + + _unitOfWork.ScrobbleRepository.Remove(eventsWithoutAnilistToken); + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to delete old scrobble events when the user has no active token"); + } + } + + /// + /// This is a task that is run on a fixed schedule (every few hours or every day) that clears out the scrobble event table + /// and offloads the data to the API server which performs the syncing to the providers. + /// + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ProcessUpdatesSinceLastSync() + { + var ctx = await PrepareScrobbleContext(); + if (ctx.TotalCount == 0) return; + + // Get all the applicable users to scrobble and set their rate limits + await PrepareUsersToScrobble(ctx); + + _logger.LogInformation("Scrobble Processing Details:" + + "\n Read Events: {ReadEventsCount}" + + "\n Want to Read Events: {WantToReadEventsCount}" + + "\n Rating Events: {RatingEventsCount}" + + "\n Users to Scrobble: {UsersToScrobbleCount}" + + "\n Total Events to Process: {TotalEvents}", + ctx.ReadEvents.Count, + ctx.Decisions.Count, + ctx.RatingEvents.Count, + ctx.Users.Count, + ctx.TotalCount); + + try + { + await ProcessReadEvents(ctx); + await ProcessRatingEvents(ctx); + await ProcessWantToReadRatingEvents(ctx); + } + catch (FlurlHttpException ex) + { + _logger.LogError(ex, "Kavita+ API or a Scrobble service may be experiencing an outage. Stopping sending data"); + return; + } + + + await SaveToDb(ctx.ProgressCounter, true); + _logger.LogInformation("Scrobbling Events is complete"); + + await CleanupOldOrBuggedEvents(); + } + + /// + /// Calculates the net want-to-read decisions by considering all events. + /// Returns events that represent the final state for each user/series pair. + /// + /// List of events for adding to want-to-read + /// List of events for removing from want-to-read + /// List of events that represent the final state (add or remove) + private static List CalculateNetWantToReadDecisions(List addEvents, List removeEvents) + { + // Create a dictionary to track the latest event for each user/series combination + var latestEvents = new Dictionary<(int SeriesId, int AppUserId), ScrobbleEvent>(); + + // Process all add events + foreach (var addEvent in addEvents) + { + var key = (addEvent.SeriesId, addEvent.AppUserId); + + if (latestEvents.TryGetValue(key, out var value) && addEvent.CreatedUtc <= value.CreatedUtc) continue; + + value = addEvent; + latestEvents[key] = value; + } + + // Process all remove events + foreach (var removeEvent in removeEvents) + { + var key = (removeEvent.SeriesId, removeEvent.AppUserId); + + if (latestEvents.TryGetValue(key, out var value) && removeEvent.CreatedUtc <= value.CreatedUtc) continue; + + value = removeEvent; + latestEvents[key] = value; + } + + // Return all events that represent the final state + return latestEvents.Values.ToList(); + } + + private async Task ProcessWantToReadRatingEvents(ScrobbleSyncContext ctx) + { + await ProcessEvents(ctx.Decisions, ctx, evt => Task.FromResult(new ScrobbleDto + { + Format = evt.Format, + AniListId = evt.AniListId, + MALId = (int?) evt.MalId, + ScrobbleEventType = evt.ScrobbleEventType, + ChapterNumber = evt.ChapterNumber, + VolumeNumber = (int?) evt.VolumeNumber, + AniListToken = evt.AppUser.AniListAccessToken ?? string.Empty, + SeriesName = evt.Series.Name, + LocalizedSeriesName = evt.Series.LocalizedName, + Year = evt.Series.Metadata.ReleaseYear + })); + + // After decisions, we need to mark all the want to read and remove from want to read as completed + var processedDecisions = ctx.Decisions.Where(d => d.IsProcessed).ToList(); + if (processedDecisions.Count > 0) + { + foreach (var scrobbleEvent in processedDecisions) + { + scrobbleEvent.IsProcessed = true; + scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; + _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); + } + await _unitOfWork.CommitAsync(); + } + } + + private async Task ProcessRatingEvents(ScrobbleSyncContext ctx) + { + await ProcessEvents(ctx.RatingEvents, ctx, evt => Task.FromResult(new ScrobbleDto + { + Format = evt.Format, + AniListId = evt.AniListId, + MALId = (int?) evt.MalId, + ScrobbleEventType = evt.ScrobbleEventType, + AniListToken = evt.AppUser.AniListAccessToken ?? string.Empty, + SeriesName = evt.Series.Name, + LocalizedSeriesName = evt.Series.LocalizedName, + Rating = evt.Rating, + Year = evt.Series.Metadata.ReleaseYear + })); + } + + private async Task ProcessReadEvents(ScrobbleSyncContext ctx) + { + // Recalculate the highest volume/chapter + foreach (var readEvt in ctx.ReadEvents) + { + // Note: this causes skewing in the scrobble history because it makes it look like there are duplicate events + readEvt.VolumeNumber = + (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(readEvt.SeriesId, + readEvt.AppUser.Id); + readEvt.ChapterNumber = + await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(readEvt.SeriesId, + readEvt.AppUser.Id); + _unitOfWork.ScrobbleRepository.Update(readEvt); + } + + await ProcessEvents(ctx.ReadEvents, ctx, async evt => new ScrobbleDto + { + Format = evt.Format, + AniListId = evt.AniListId, + MALId = (int?) evt.MalId, + ScrobbleEventType = evt.ScrobbleEventType, + ChapterNumber = evt.ChapterNumber, + VolumeNumber = (int?) evt.VolumeNumber, + AniListToken = evt.AppUser.AniListAccessToken ?? string.Empty, + SeriesName = evt.Series.Name, + LocalizedSeriesName = evt.Series.LocalizedName, + ScrobbleDateUtc = evt.LastModifiedUtc, + Year = evt.Series.Metadata.ReleaseYear, + StartedReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetFirstProgressForSeries(evt.SeriesId, evt.AppUser.Id), + LatestReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(evt.SeriesId, evt.AppUser.Id), + }); + } + + /// + /// Returns true if the user token is valid + /// + /// + /// + /// If the token is not, adds a scrobble error + private async Task ValidateUserToken(ScrobbleEvent evt) + { + if (!TokenService.HasTokenExpired(evt.AppUser.AniListAccessToken)) + return true; + + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError + { + Comment = "AniList token has expired and needs rotating. Scrobbling wont work until then", + Details = $"User: {evt.AppUser.UserName}, Expired: {TokenService.GetTokenExpiry(evt.AppUser.AniListAccessToken)}", + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + await _unitOfWork.CommitAsync(); + return false; + } + + /// + /// Returns true if the series can be scrobbled + /// + /// + /// + /// If the series cannot be scrobbled, adds a scrobble error + private async Task ValidateSeriesCanBeScrobbled(ScrobbleEvent evt) + { + if (evt.Series is { IsBlacklisted: false, DontMatch: false }) + return true; + + _logger.LogInformation("Series {SeriesName} ({SeriesId}) can't be matched and thus cannot scrobble this event", + evt.Series.Name, evt.SeriesId); + + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError + { + Comment = UnknownSeriesErrorMessage, + Details = $"User: {evt.AppUser.UserName} Series: {evt.Series.Name}", + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + + evt.SetErrorMessage(UnknownSeriesErrorMessage); + evt.ProcessDateUtc = DateTime.UtcNow; + _unitOfWork.ScrobbleRepository.Update(evt); + await _unitOfWork.CommitAsync(); + return false; + } + + /// + /// Removed Special parses numbers from chatter and volume numbers + /// + /// + /// + private static ScrobbleDto NormalizeScrobbleData(ScrobbleDto data) + { + // We need to handle the encoding and changing it to the old one until we can update the API layer to handle these + // which could happen in v0.8.3 + if (data.VolumeNumber is Parser.SpecialVolumeNumber or Parser.DefaultChapterNumber) + { + data.VolumeNumber = 0; + } + + + if (data.ChapterNumber is Parser.DefaultChapterNumber) + { + data.ChapterNumber = 0; + } + + + return data; + } + + /// + /// Loops through all events, and post them to K+ + /// + /// + /// + /// + private async Task ProcessEvents(IEnumerable events, ScrobbleSyncContext ctx, Func> createEvent) + { + foreach (var evt in events.Where(CanProcessScrobbleEvent)) + { + _logger.LogDebug("Processing Scrobble Events: {Count} / {Total}", ctx.ProgressCounter, ctx.TotalCount); + ctx.ProgressCounter++; + + if (!await ValidateUserToken(evt)) continue; + if (!await ValidateSeriesCanBeScrobbled(evt)) continue; + + var count = await SetAndCheckRateLimit(ctx.RateLimits, evt.AppUser, ctx.License); + if (count == 0) + { + if (ctx.Users.Count == 1) break; + continue; + } + + try + { + var data = NormalizeScrobbleData(await createEvent(evt)); + + ctx.RateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, ctx.License, evt); + + evt.IsProcessed = true; + evt.ProcessDateUtc = DateTime.UtcNow; + _unitOfWork.ScrobbleRepository.Update(evt); + } + catch (FlurlHttpException) + { + // If a flurl exception occured, the API is likely down. Kill processing + throw; + } + catch (KavitaException ex) + { + if (ex.Message.Contains("Access token is invalid")) + { + _logger.LogCritical(ex, "Access Token for AppUserId: {AppUserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id); + evt.SetErrorMessage(AccessTokenErrorMessage); + _unitOfWork.ScrobbleRepository.Update(evt); + + // Ensure series with this error do not get re-processed next sync + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError + { + Comment = AccessTokenErrorMessage, + Details = $"{evt.AppUser.UserName} has an invalid access token (K+ Error)", + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId, + }); + } + } + catch (Exception ex) + { + /* Swallow as it's already been handled in PostScrobbleUpdate */ + _logger.LogError(ex, "Error processing event {EventId}", evt.Id); + } + + await SaveToDb(ctx.ProgressCounter); + + // We can use count to determine how long to sleep based on rate gain. It might be specific to AniList, but we can model others + var delay = count > 10 ? TimeSpan.FromMilliseconds(ScrobbleSleepTime) : TimeSpan.FromSeconds(60); + await Task.Delay(delay); + } + + await SaveToDb(ctx.ProgressCounter, true); + } + + /// + /// Save changes every five updates + /// + /// + /// Ignore update count check + private async Task SaveToDb(int progressCounter, bool force = false) + { + if ((force || progressCounter % 5 == 0) && _unitOfWork.HasChanges()) + { + _logger.LogDebug("Saving Scrobbling Event Processing Progress"); + await _unitOfWork.CommitAsync(); + } + } + + /// + /// If no errors have been logged for the given series, creates a new Unknown series error, and blacklists the series + /// + /// + /// + private async Task MarkSeriesAsUnknown(ScrobbleDto data, ScrobbleEvent evt) + { + if (await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) return; + + // Create a new ExternalMetadata entry to indicate that this is not matchable + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(evt.SeriesId, SeriesIncludes.ExternalMetadata); + if (series == null) return; + + series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata {SeriesId = evt.SeriesId}; + series.IsBlacklisted = true; + _unitOfWork.SeriesRepository.Update(series); + + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError + { + Comment = UnknownSeriesErrorMessage, + Details = data.SeriesName, + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + } + + /// + /// Makes the K+ request, and handles any exceptions that occur + /// + /// Data to send to K+ + /// K+ license key + /// Related scrobble event + /// + /// Exceptions may be rethrown as a KavitaException + /// Some FlurlHttpException are also rethrown + public async Task PostScrobbleUpdate(ScrobbleDto data, string license, ScrobbleEvent evt) + { + try + { + var response = await _kavitaPlusApiService.PostScrobbleUpdate(data, license); + + _logger.LogDebug("K+ API Scrobble response for series {SeriesName}: Successful {Successful}, ErrorMessage {ErrorMessage}, ExtraInformation: {ExtraInformation}, RateLeft: {RateLeft}", + data.SeriesName, response.Successful, response.ErrorMessage, response.ExtraInformation, response.RateLeft); + + if (response.Successful || response.ErrorMessage == null) return response.RateLeft; + + // Might want to log this under ScrobbleError + if (response.ErrorMessage.Contains("Too Many Requests")) + { + _logger.LogInformation("Hit Too many requests while posting scrobble updates, sleeping to regain requests and retrying"); + await Task.Delay(TimeSpan.FromMinutes(10)); + return await PostScrobbleUpdate(data, license, evt); + } + + if (response.ErrorMessage.Contains("Unauthorized")) + { + _logger.LogCritical("Kavita+ responded with Unauthorized. Please check your subscription"); + await _licenseService.HasActiveLicense(true); + evt.SetErrorMessage(InvalidKPlusLicenseErrorMessage); + throw new KavitaException("Kavita+ responded with Unauthorized. Please check your subscription"); + } + + if (response.ErrorMessage.Contains("Access token is invalid")) + { + evt.SetErrorMessage(AccessTokenErrorMessage); + throw new KavitaException("Access token is invalid"); + } + + if (response.ErrorMessage.Contains("Unknown Series")) + { + // Log the Series name and Id in ScrobbleErrors + _logger.LogInformation("Kavita+ was unable to match the series: {SeriesName}", evt.Series.Name); + await MarkSeriesAsUnknown(data, evt); + evt.SetErrorMessage(UnknownSeriesErrorMessage); + } else if (response.ErrorMessage.StartsWith("Review")) + { + // Log the Series name and Id in ScrobbleErrors + _logger.LogInformation("Kavita+ was unable to save the review"); + if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) + { + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() + { + Comment = response.ErrorMessage, + Details = data.SeriesName, + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + } + evt.SetErrorMessage(ReviewFailedErrorMessage); + } + + return response.RateLeft; + } + catch (FlurlHttpException ex) + { + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + + if (errorMessage.Contains("Too Many Requests")) + { + _logger.LogInformation("Hit Too many requests while posting scrobble updates, sleeping to regain requests and retrying"); + await Task.Delay(TimeSpan.FromMinutes(10)); + return await PostScrobbleUpdate(data, license, evt); + } + + _logger.LogError(ex, "Scrobbling to Kavita+ API failed due to error: {ErrorMessage}", ex.Message); + if (ex.StatusCode == 500 || ex.Message.Contains("Call failed with status code 500 (Internal Server Error)")) + { + if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) + { + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() + { + Comment = UnknownSeriesErrorMessage, + Details = data.SeriesName, + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + } + evt.SetErrorMessage(BadPayLoadErrorMessage); + throw new KavitaException(BadPayLoadErrorMessage); + } + throw; + } + } + + #endregion + + #region BackFill + + + /// + /// This will backfill events from existing progress history, ratings, and want to read for users that have a valid license + /// + /// Defaults to 0 meaning all users. Allows a userId to be set if a scrobble key is added to a user + public async Task CreateEventsFromExistingHistory(int userId = 0) + { + if (!await _licenseService.HasActiveLicense()) return; + + if (userId != 0) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null || string.IsNullOrEmpty(user.AniListAccessToken)) return; + if (user.HasRunScrobbleEventGeneration) + { + _logger.LogWarning("User {UserName} has already run scrobble event generation, Kavita will not generate more events", user.UserName); + return; + } + } + + var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) + .ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling); + + var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()) + .Where(l => userId == 0 || userId == l.Id) + .Where(u => !u.HasRunScrobbleEventGeneration) + .Select(u => u.Id); + + foreach (var uId in userIds) + { + await CreateEventsFromExistingHistoryForUser(uId, libAllowsScrobbling); + } + } + + /// + /// Creates wantToRead, rating, reviews, and series progress events for the suer + /// + /// + /// + private async Task CreateEventsFromExistingHistoryForUser(int userId, Dictionary libAllowsScrobbling) + { + var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(userId); + foreach (var wtr in wantToRead) + { + if (!libAllowsScrobbling[wtr.LibraryId]) continue; + await ScrobbleWantToReadUpdate(userId, wtr.Id, true); + } + + var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(userId); + foreach (var rating in ratings) + { + if (!libAllowsScrobbling[rating.Series.LibraryId]) continue; + await ScrobbleRatingUpdate(userId, rating.SeriesId, rating.Rating); + } + + var reviews = await _unitOfWork.UserRepository.GetSeriesWithReviews(userId); + foreach (var review in reviews.Where(r => !string.IsNullOrEmpty(r.Review))) + { + if (!libAllowsScrobbling[review.Series.LibraryId]) continue; + await ScrobbleReviewUpdate(userId, review.SeriesId, string.Empty, review.Review!); + } + + var seriesWithProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, userId, + new UserParams(), new FilterDto + { + ReadStatus = new ReadStatus + { + Read = true, + InProgress = true, + NotRead = false + }, + Libraries = libAllowsScrobbling.Keys.Where(k => libAllowsScrobbling[k]).ToList() + }); + + foreach (var series in seriesWithProgress.Where(series => series.PagesRead > 0)) + { + if (!libAllowsScrobbling[series.LibraryId]) continue; + await ScrobbleReadingUpdate(userId, series.Id); + } + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user != null) + { + user.HasRunScrobbleEventGeneration = true; + user.ScrobbleEventGenerationRan = DateTime.UtcNow; + await _unitOfWork.CommitAsync(); + } + } + + public async Task CreateEventsFromExistingHistoryForSeries(int seriesId) + { + if (!await _licenseService.HasActiveLicense()) return; + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null || !series.Library.AllowScrobbling) return; + + _logger.LogInformation("Creating Scrobbling events for Series {SeriesName}", series.Name); + + var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(u => u.Id); + + foreach (var uId in userIds) + { + // Handle "Want to Read" updates specific to the series + var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId); + foreach (var wtr in wantToRead.Where(wtr => wtr.Id == seriesId)) + { + await ScrobbleWantToReadUpdate(uId, wtr.Id, true); + } + + // Handle ratings specific to the series + var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(uId); + foreach (var rating in ratings.Where(rating => rating.SeriesId == seriesId)) + { + await ScrobbleRatingUpdate(uId, rating.SeriesId, rating.Rating); + } + + // Handle review specific to the series + var reviews = await _unitOfWork.UserRepository.GetSeriesWithReviews(uId); + foreach (var review in reviews.Where(r => r.SeriesId == seriesId && !string.IsNullOrEmpty(r.Review))) + { + await ScrobbleReviewUpdate(uId, review.SeriesId, string.Empty, review.Review!); + } + + // Handle progress updates for the specific series + await ScrobbleReadingUpdate(uId, seriesId); + } + } + + #endregion + + /// + /// Removes all events (active) that are tied to a now-on hold series + /// + /// + /// + public async Task ClearEventsForSeries(int userId, int seriesId) + { + _logger.LogInformation("Clearing Pre-existing Scrobble events for Series {SeriesId} by User {AppUserId} as Series is now on hold list", seriesId, userId); + + var events = await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId); + _unitOfWork.ScrobbleRepository.Remove(events); + await _unitOfWork.CommitAsync(); + } + + /// + /// Removes all events that have been processed that are 7 days old + /// + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ClearProcessedEvents() + { + const int daysAgo = 7; + var events = await _unitOfWork.ScrobbleRepository.GetProcessedEvents(daysAgo); + _unitOfWork.ScrobbleRepository.Remove(events); + _logger.LogInformation("Removing {Count} scrobble events that have been processed {DaysAgo}+ days ago", events.Count, daysAgo); + await _unitOfWork.CommitAsync(); + } + + private static bool CanProcessScrobbleEvent(ScrobbleEvent readEvent) + { + var userProviders = GetUserProviders(readEvent.AppUser); + switch (readEvent.Series.Library.Type) + { + case LibraryType.Manga when MangaProviders.Intersect(userProviders).Any(): + case LibraryType.Comic when ComicProviders.Intersect(userProviders).Any(): + case LibraryType.Book when BookProviders.Intersect(userProviders).Any(): + case LibraryType.LightNovel when LightNovelProviders.Intersect(userProviders).Any(): + return true; + default: + return false; + } + } + + private static List GetUserProviders(AppUser appUser) + { + var providers = new List(); + if (!string.IsNullOrEmpty(appUser.AniListAccessToken)) providers.Add(ScrobbleProvider.AniList); + + return providers; } private async Task SetAndCheckRateLimit(IDictionary userRateLimits, AppUser user, string license) @@ -969,7 +1402,7 @@ public class ScrobblingService : IScrobblingService } catch (Exception ex) { - _logger.LogInformation("User {UserName} had an issue figuring out rate: {Message}", user.UserName, ex.Message); + _logger.LogInformation(ex, "User {UserName} had an issue figuring out rate: {Message}", user.UserName, ex.Message); userRateLimits.Add(user.Id, 0); } @@ -982,9 +1415,4 @@ public class ScrobblingService : IScrobblingService return count; } - public static string CreateUrl(string url, long? id) - { - if (id is null or 0) return string.Empty; - return $"{url}{id}/"; - } } diff --git a/API/Services/Plus/SmartCollectionSyncService.cs b/API/Services/Plus/SmartCollectionSyncService.cs index d5bbf2cce..1bd0dfb6b 100644 --- a/API/Services/Plus/SmartCollectionSyncService.cs +++ b/API/Services/Plus/SmartCollectionSyncService.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; +using API.DTOs.KavitaPlus.ExternalMetadata; using API.DTOs.Scrobbling; using API.Entities; using API.Entities.Enums; @@ -20,7 +21,7 @@ using Microsoft.Extensions.Logging; namespace API.Services.Plus; #nullable enable -sealed class SeriesCollection +internal sealed class SeriesCollection { public required IList Series { get; set; } public required string Summary { get; set; } @@ -158,7 +159,7 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService var normalizedLocalizedSeriesName = seriesInfo.LocalizedSeriesName?.ToNormalized(); // Search for existing series in the collection - var formats = GetMangaFormats(seriesInfo.PlusMediaFormat); + var formats = seriesInfo.PlusMediaFormat.GetMangaFormats(); var existingSeries = collection.Items.FirstOrDefault(s => (s.Name.ToNormalized() == normalizedSeriesName || s.NormalizedName == normalizedSeriesName || @@ -243,19 +244,7 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService } } - private static IList GetMangaFormats(MediaFormat? mediaFormat) - { - if (mediaFormat == null) return [MangaFormat.Archive]; - return mediaFormat switch - { - MediaFormat.Manga => [MangaFormat.Archive, MangaFormat.Image], - MediaFormat.Comic => [MangaFormat.Archive], - MediaFormat.LightNovel => [MangaFormat.Epub, MangaFormat.Pdf], - MediaFormat.Book => [MangaFormat.Epub, MangaFormat.Pdf], - MediaFormat.Unknown => [MangaFormat.Archive], - _ => [MangaFormat.Archive] - }; - } + private static long GetStackId(string url) { @@ -270,13 +259,7 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; var seriesForStack = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stack?stackId=" + stackId) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(license) .GetJsonAsync(); return seriesForStack; diff --git a/API/Services/Plus/WantToReadSyncService.cs b/API/Services/Plus/WantToReadSyncService.cs new file mode 100644 index 000000000..a6d536911 --- /dev/null +++ b/API/Services/Plus/WantToReadSyncService.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; +using API.DTOs.SeriesDetail; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using Flurl.Http; +using Hangfire; +using Kavita.Common; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Bcpg.Sig; + +namespace API.Services.Plus; + + +public interface IWantToReadSyncService +{ + Task Sync(); +} + +/// +/// Responsible for syncing Want To Read from upstream providers with Kavita +/// +public class WantToReadSyncService : IWantToReadSyncService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly ILicenseService _licenseService; + + public WantToReadSyncService(IUnitOfWork unitOfWork, ILogger logger, ILicenseService licenseService) + { + _unitOfWork = unitOfWork; + _logger = logger; + _licenseService = licenseService; + } + + public async Task Sync() + { + if (!await _licenseService.HasActiveLicense()) return; + + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + + var users = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead | AppUserIncludes.UserPreferences); + foreach (var user in users.Where(u => u.UserPreferences.WantToReadSync)) + { + if (string.IsNullOrEmpty(user.MalUserName) && string.IsNullOrEmpty(user.AniListAccessToken)) continue; + + try + { + _logger.LogInformation("Syncing want to read for user: {UserName}", user.UserName); + var wantToReadSeries = + await ( + $"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/want-to-read?malUsername={user.MalUserName}&aniListToken={user.AniListAccessToken}") + .WithKavitaPlusHeaders(license) + .WithTimeout( + TimeSpan.FromSeconds(120)) // Give extra time as MAL + AniList can result in a lot of data + .GetJsonAsync>(); + + // Match the series (note: There may be duplicates in the final result) + foreach (var unmatchedSeries in wantToReadSeries) + { + var match = await _unitOfWork.SeriesRepository.MatchSeries(unmatchedSeries); + if (match == null) + { + continue; + } + + // There is a match, add it + user.WantToRead.Add(new AppUserWantToRead() + { + SeriesId = match.Id, + }); + _logger.LogDebug("Added {MatchName} ({Format}) to Want to Read", match.Name, match.Format); + } + + // Remove existing Want to Read that are duplicates + user.WantToRead = user.WantToRead.DistinctBy(d => d.SeriesId).ToList(); + + // TODO: Need to write in the history table the last sync time + + // Save the left over entities + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + // Trigger CleanupService to cleanup any series in WantToRead that don't belong + RecurringJob.TriggerJob(TaskScheduler.RemoveFromWantToReadTaskId); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when processing want to read series sync for {User}", user.UserName); + } + } + + } + + // Allow syncing if there are any libraries that have an appropriate Provider, the user has the appropriate token, and the last Sync validates + // private async Task CanSync(AppUser? user) + // { + // + // if (collection is not {Source: ScrobbleProvider.Mal}) return false; + // if (string.IsNullOrEmpty(collection.SourceUrl)) return false; + // if (collection.LastSyncUtc.Truncate(TimeSpan.TicksPerHour) >= DateTime.UtcNow.AddDays(SyncDelta).Truncate(TimeSpan.TicksPerHour)) return false; + // return true; + // } +} diff --git a/API/Services/RatingService.cs b/API/Services/RatingService.cs new file mode 100644 index 000000000..ccaebba69 --- /dev/null +++ b/API/Services/RatingService.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Services.Plus; +using Hangfire; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IRatingService +{ + /// + /// Updates the users' rating for a given series + /// + /// Should include ratings + /// + /// + Task UpdateSeriesRating(AppUser user, UpdateRatingDto updateRatingDto); + + /// + /// Updates the users' rating for a given chapter + /// + /// Should include ratings + /// chapterId must be set + /// + Task UpdateChapterRating(AppUser user, UpdateRatingDto updateRatingDto); +} + +public class RatingService: IRatingService +{ + + private readonly IUnitOfWork _unitOfWork; + private readonly IScrobblingService _scrobblingService; + private readonly ILogger _logger; + + public RatingService(IUnitOfWork unitOfWork, IScrobblingService scrobblingService, ILogger logger) + { + _unitOfWork = unitOfWork; + _scrobblingService = scrobblingService; + _logger = logger; + } + + public async Task UpdateSeriesRating(AppUser user, UpdateRatingDto updateRatingDto) + { + var userRating = + await _unitOfWork.UserRepository.GetUserRatingAsync(updateRatingDto.SeriesId, user.Id) ?? + new AppUserRating(); + + try + { + userRating.Rating = Math.Clamp(updateRatingDto.UserRating, 0f, 5f); + userRating.HasBeenRated = true; + userRating.SeriesId = updateRatingDto.SeriesId; + + if (userRating.Id == 0) + { + user.Ratings ??= new List(); + user.Ratings.Add(userRating); + } + + _unitOfWork.UserRepository.Update(user); + + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) + { + BackgroundJob.Enqueue(() => + _scrobblingService.ScrobbleRatingUpdate(user.Id, updateRatingDto.SeriesId, + userRating.Rating)); + return true; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception saving rating"); + } + + await _unitOfWork.RollbackAsync(); + user.Ratings?.Remove(userRating); + + return false; + } + + public async Task UpdateChapterRating(AppUser user, UpdateRatingDto updateRatingDto) + { + if (updateRatingDto.ChapterId == null) + { + return false; + } + + var userRating = + await _unitOfWork.UserRepository.GetUserChapterRatingAsync(user.Id, updateRatingDto.ChapterId.Value) ?? + new AppUserChapterRating(); + + try + { + userRating.Rating = Math.Clamp(updateRatingDto.UserRating, 0f, 5f); + userRating.HasBeenRated = true; + userRating.SeriesId = updateRatingDto.SeriesId; + userRating.ChapterId = updateRatingDto.ChapterId.Value; + + if (userRating.Id == 0) + { + user.ChapterRatings ??= new List(); + user.ChapterRatings.Add(userRating); + } + + _unitOfWork.UserRepository.Update(user); + + await _unitOfWork.CommitAsync(); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception saving rating"); + } + + await _unitOfWork.RollbackAsync(); + user.ChapterRatings?.Remove(userRating); + + return false; + } + +} diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index dd4b824b8..3b3cb37d5 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -122,6 +122,7 @@ public class ReaderService : IReaderService var seenVolume = new Dictionary(); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); if (series == null) throw new KavitaException("series-doesnt-exist"); + foreach (var chapter in chapters) { var userProgress = GetUserProgressForChapter(user, chapter); @@ -135,10 +136,6 @@ public class ReaderService : IReaderService SeriesId = seriesId, ChapterId = chapter.Id, LibraryId = series.LibraryId, - Created = DateTime.Now, - CreatedUtc = DateTime.UtcNow, - LastModified = DateTime.Now, - LastModifiedUtc = DateTime.UtcNow }); } else @@ -206,7 +203,7 @@ public class ReaderService : IReaderService /// Must have Progresses populated /// /// - private static AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter) + private AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter) { AppUserProgress? userProgress = null; @@ -226,11 +223,12 @@ public class ReaderService : IReaderService var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList(); if (progresses.Count > 1) { - user.Progresses = new List - { - user.Progresses.First() - }; + var highestProgress = progresses.Max(x => x.PagesRead); + var firstProgress = progresses.OrderBy(p => p.LastModifiedUtc).First(); + firstProgress.PagesRead = highestProgress; + user.Progresses = [firstProgress]; userProgress = user.Progresses.First(); + _logger.LogInformation("Trying to save progress and multiple progress entries exist, deleting and rewriting with highest progress rate: {@Progress}", userProgress); } } @@ -274,10 +272,6 @@ public class ReaderService : IReaderService ChapterId = progressDto.ChapterId, LibraryId = progressDto.LibraryId, BookScrollId = progressDto.BookScrollId, - Created = DateTime.Now, - CreatedUtc = DateTime.UtcNow, - LastModified = DateTime.Now, - LastModifiedUtc = DateTime.UtcNow }); _unitOfWork.UserRepository.Update(userWithProgress); } diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 581690733..6ff8d19de 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -12,7 +12,7 @@ public interface IReadingItemService int GetNumberOfPages(string filePath, MangaFormat format); string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); - ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type); + ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata); } public class ReadingItemService : IReadingItemService @@ -52,7 +52,7 @@ public class ReadingItemService : IReadingItemService /// private ComicInfo? GetComicInfo(string filePath) { - if (Parser.IsEpub(filePath)) + if (Parser.IsEpub(filePath) || Parser.IsPdf(filePath)) { return _bookService.GetComicInfo(filePath); } @@ -71,11 +71,12 @@ public class ReadingItemService : IReadingItemService /// Path of a file /// /// Library type to determine parsing to perform - public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + /// Enable Metadata parsing overriding filename parsing + public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { try { - var info = Parse(path, rootPath, libraryRoot, type); + var info = Parse(path, rootPath, libraryRoot, type, enableMetadata); if (info == null) { _logger.LogError("Unable to parse any meaningful information out of file {FilePath}", path); @@ -174,28 +175,29 @@ public class ReadingItemService : IReadingItemService /// /// /// + /// /// - private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type) + private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_pdfParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_basicParser.IsApplicable(path, type)) { - return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } return null; diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 1394b131a..8c4f63430 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -49,6 +50,14 @@ public interface IReadingListService Task CreateReadingListsFromSeries(int libraryId, int seriesId); Task GenerateReadingListCoverImage(int readingListId); + /// + /// Check, and update if needed, all reading lists' AgeRating who contain the passed series + /// + /// The series whose age rating is being updated + /// The new (uncommited) age rating of the series + /// + /// This method does not commit changes + Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating); } /// @@ -95,7 +104,13 @@ public class ReadingListService : IReadingListService { title = $"Volume {Parser.CleanSpecialTitle(item.VolumeNumber)}"; } - } else { + } + else if (item.VolumeNumber == Parser.SpecialVolume) + { + title = specialTitle; + } + else + { title = $"Volume {specialTitle}"; } } @@ -458,6 +473,7 @@ public class ReadingListService : IReadingListService _logger.LogInformation("Processing Reading Lists for {SeriesName}", series.Name); var user = await _unitOfWork.UserRepository.GetDefaultAdminUser(); series.Metadata ??= new SeriesMetadataBuilder().Build(); + foreach (var chapter in series.Volumes.SelectMany(v => v.Chapters)) { var pairs = new List>(); @@ -538,13 +554,13 @@ public class ReadingListService : IReadingListService var maxPairs = Math.Max(arcs.Length, arcNumbers.Length); for (var i = 0; i < maxPairs; i++) { - var arcNumber = int.MaxValue.ToString(); + var arcNumber = int.MaxValue.ToString(CultureInfo.InvariantCulture); if (arcNumbers.Length > i) { arcNumber = arcNumbers[i]; } - if (string.IsNullOrEmpty(arcs[i]) || !int.TryParse(arcNumber, out _)) continue; + if (string.IsNullOrEmpty(arcs[i]) || !int.TryParse(arcNumber, CultureInfo.InvariantCulture, out _)) continue; data.Add(new Tuple(arcs[i], arcNumber)); } @@ -563,14 +579,14 @@ public class ReadingListService : IReadingListService { CblName = cblReading.Name, Success = CblImportResult.Success, - Results = new List(), + Results = [], SuccessfulInserts = new List() }; if (IsCblEmpty(cblReading, importSummary, out var readingListFromCbl)) return readingListFromCbl; - // Is there another reading list with the same name? - if (await _unitOfWork.ReadingListRepository.ReadingListExists(cblReading.Name)) + // Is there another reading list with the same name on the user's account? + if (await _unitOfWork.ReadingListRepository.ReadingListExistsForUser(cblReading.Name, userId)) { importSummary.Success = CblImportResult.Fail; importSummary.Results.Add(new CblBookResult @@ -585,9 +601,6 @@ public class ReadingListService : IReadingListService var userSeries = (await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); - // How can we match properly with ComicVine library when year is part of the series unless we do this in 2 passes and see which has a better match - - if (userSeries.Count == 0) { // Report that no series exist in the reading list @@ -843,4 +856,22 @@ public class ReadingListService : IReadingListService return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; } + + public async Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating) + { + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsBySeriesId(seriesId); + foreach (var readingList in readingLists) + { + var seriesIds = readingList.Items.Select(item => item.SeriesId).ToList(); + seriesIds.Remove(seriesId); // Don't get AgeRating from database + + var maxAgeRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds); + if (ageRating > maxAgeRating) + { + maxAgeRating = ageRating; + } + + readingList.AgeRating = maxAgeRating; + } + } } diff --git a/API/Services/ReadingProfileService.cs b/API/Services/ReadingProfileService.cs new file mode 100644 index 000000000..4c3dab006 --- /dev/null +++ b/API/Services/ReadingProfileService.cs @@ -0,0 +1,454 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers.Builders; +using AutoMapper; +using Kavita.Common; + +namespace API.Services; +#nullable enable + +public interface IReadingProfileService +{ + /// + /// Returns the ReadingProfile that should be applied to the given series, walks up the tree. + /// Series (Implicit) -> Series (User) -> Library (User) -> Default + /// + /// + /// + /// + /// + Task GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false); + + /// + /// Creates a new reading profile for a user. Name must be unique per user + /// + /// + /// + /// + Task CreateReadingProfile(int userId, UserReadingProfileDto dto); + Task PromoteImplicitProfile(int userId, int profileId); + + /// + /// Updates the implicit reading profile for a series, creates one if none exists + /// + /// + /// + /// + /// + Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto); + + /// + /// Updates the non-implicit reading profile for the given series, and removes implicit profiles + /// + /// + /// + /// + /// + Task UpdateParent(int userId, int seriesId, UserReadingProfileDto dto); + + /// + /// Updates a given reading profile for a user + /// + /// + /// + /// + /// Does not update connected series and libraries + Task UpdateReadingProfile(int userId, UserReadingProfileDto dto); + + /// + /// Deletes a given profile for a user + /// + /// + /// + /// + /// + /// The default profile for the user cannot be deleted + Task DeleteReadingProfile(int userId, int profileId); + + /// + /// Binds the reading profile to the series, and remove the implicit RP from the series if it exists + /// + /// + /// + /// + /// + Task AddProfileToSeries(int userId, int profileId, int seriesId); + /// + /// Binds the reading profile to many series, and remove the implicit RP from the series if it exists + /// + /// + /// + /// + /// + Task BulkAddProfileToSeries(int userId, int profileId, IList seriesIds); + /// + /// Remove all reading profiles bound to the series + /// + /// + /// + /// + Task ClearSeriesProfile(int userId, int seriesId); + + /// + /// Bind the reading profile to the library + /// + /// + /// + /// + /// + Task AddProfileToLibrary(int userId, int profileId, int libraryId); + /// + /// Remove the reading profile bound to the library, if it exists + /// + /// + /// + /// + Task ClearLibraryProfile(int userId, int libraryId); + /// + /// Returns the bound Reading Profile to a Library + /// + /// + /// + /// + Task GetReadingProfileDtoForLibrary(int userId, int libraryId); +} + +public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper): IReadingProfileService +{ + /// + /// Tries to resolve the Reading Profile for a given Series. Will first check (optionally) Implicit profiles, then check for a bound Series profile, then a bound + /// Library profile, then default to the default profile. + /// + /// + /// + /// + /// + /// + public async Task GetReadingProfileForSeries(int userId, int seriesId, bool skipImplicit = false) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, skipImplicit); + + // If there is an implicit, send back + var implicitProfile = + profiles.FirstOrDefault(p => p.SeriesIds.Contains(seriesId) && p.Kind == ReadingProfileKind.Implicit); + if (implicitProfile != null) return implicitProfile; + + // Next check for a bound Series profile + var seriesProfile = profiles + .FirstOrDefault(p => p.SeriesIds.Contains(seriesId) && p.Kind != ReadingProfileKind.Implicit); + if (seriesProfile != null) return seriesProfile; + + // Check for a library bound profile + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null) throw new KavitaException(await localizationService.Translate(userId, "series-doesnt-exist")); + + var libraryProfile = profiles + .FirstOrDefault(p => p.LibraryIds.Contains(series.LibraryId) && p.Kind != ReadingProfileKind.Implicit); + if (libraryProfile != null) return libraryProfile; + + // Fallback to the default profile + return profiles.First(p => p.Kind == ReadingProfileKind.Default); + } + + public async Task GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false) + { + return mapper.Map(await GetReadingProfileForSeries(userId, seriesId, skipImplicit)); + } + + public async Task UpdateParent(int userId, int seriesId, UserReadingProfileDto dto) + { + var parentProfile = await GetReadingProfileForSeries(userId, seriesId, true); + + UpdateReaderProfileFields(parentProfile, dto, false); + unitOfWork.AppUserReadingProfileRepository.Update(parentProfile); + + // Remove the implicit profile when we UpdateParent (from reader) as it is implied that we are already bound with a non-implicit profile + await DeleteImplicateReadingProfilesForSeries(userId, [seriesId]); + + await unitOfWork.CommitAsync(); + return mapper.Map(parentProfile); + } + + public async Task UpdateReadingProfile(int userId, UserReadingProfileDto dto) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, dto.Id); + if (profile == null) throw new KavitaException("profile-does-not-exist"); + + UpdateReaderProfileFields(profile, dto); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + + await unitOfWork.CommitAsync(); + return mapper.Map(profile); + } + + public async Task CreateReadingProfile(int userId, UserReadingProfileDto dto) + { + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + + if (await unitOfWork.AppUserReadingProfileRepository.IsProfileNameInUse(userId, dto.Name)) throw new KavitaException("name-already-in-use"); + + var newProfile = new AppUserReadingProfileBuilder(user.Id).Build(); + UpdateReaderProfileFields(newProfile, dto); + + unitOfWork.AppUserReadingProfileRepository.Add(newProfile); + user.ReadingProfiles.Add(newProfile); + + await unitOfWork.CommitAsync(); + + return mapper.Map(newProfile); + } + + /// + /// Promotes the implicit profile to a user profile. Removes the series from other profiles. + /// + /// + /// + /// + public async Task PromoteImplicitProfile(int userId, int profileId) + { + // Get all the user's profiles including the implicit + var allUserProfiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, false); + var profileToPromote = allUserProfiles.First(r => r.Id == profileId); + var seriesId = profileToPromote.SeriesIds[0]; // An Implicit series can only be bound to 1 Series + + // Check if there are any reading profiles (Series) already bound to the series + var existingSeriesProfile = allUserProfiles.FirstOrDefault(r => r.SeriesIds.Contains(seriesId) && r.Kind == ReadingProfileKind.User); + if (existingSeriesProfile != null) + { + existingSeriesProfile.SeriesIds.Remove(seriesId); + unitOfWork.AppUserReadingProfileRepository.Update(existingSeriesProfile); + } + + // Convert the implicit profile into a proper Series + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null) throw new KavitaException("series-doesnt-exist"); // Shouldn't happen + + profileToPromote.Kind = ReadingProfileKind.User; + profileToPromote.Name = await localizationService.Translate(userId, "generated-reading-profile-name", series.Name); + profileToPromote.Name = EnsureUniqueProfileName(allUserProfiles, profileToPromote.Name); + profileToPromote.NormalizedName = profileToPromote.Name.ToNormalized(); + unitOfWork.AppUserReadingProfileRepository.Update(profileToPromote); + + await unitOfWork.CommitAsync(); + + return mapper.Map(profileToPromote); + } + + private static string EnsureUniqueProfileName(IList allUserProfiles, string name) + { + var counter = 1; + var newName = name; + while (allUserProfiles.Any(p => p.Name == newName)) + { + newName = $"{name} ({counter})"; + counter++; + } + + return newName; + } + + public async Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto) + { + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var existingProfile = profiles.FirstOrDefault(rp => rp.Kind == ReadingProfileKind.Implicit && rp.SeriesIds.Contains(seriesId)); + + // Series already had an implicit profile, update it + if (existingProfile is {Kind: ReadingProfileKind.Implicit}) + { + UpdateReaderProfileFields(existingProfile, dto, false); + unitOfWork.AppUserReadingProfileRepository.Update(existingProfile); + await unitOfWork.CommitAsync(); + + return mapper.Map(existingProfile); + } + + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId) ?? throw new KeyNotFoundException(); + var newProfile = new AppUserReadingProfileBuilder(userId) + .WithSeries(series) + .WithKind(ReadingProfileKind.Implicit) + .Build(); + + // Set name to something fitting for debugging if needed + UpdateReaderProfileFields(newProfile, dto, false); + newProfile.Name = $"Implicit Profile for {seriesId}"; + newProfile.NormalizedName = newProfile.Name.ToNormalized(); + + user.ReadingProfiles.Add(newProfile); + await unitOfWork.CommitAsync(); + + return mapper.Map(newProfile); + } + + public async Task DeleteReadingProfile(int userId, int profileId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + if (profile.Kind == ReadingProfileKind.Default) throw new KavitaException("cant-delete-default-profile"); + + unitOfWork.AppUserReadingProfileRepository.Remove(profile); + await unitOfWork.CommitAsync(); + } + + public async Task AddProfileToSeries(int userId, int profileId, int seriesId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + await DeleteImplicitAndRemoveFromUserProfiles(userId, [seriesId], []); + + profile.SeriesIds.Add(seriesId); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + + await unitOfWork.CommitAsync(); + } + + public async Task BulkAddProfileToSeries(int userId, int profileId, IList seriesIds) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + await DeleteImplicitAndRemoveFromUserProfiles(userId, seriesIds, []); + + profile.SeriesIds.AddRange(seriesIds.Except(profile.SeriesIds)); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + + await unitOfWork.CommitAsync(); + } + + public async Task ClearSeriesProfile(int userId, int seriesId) + { + await DeleteImplicitAndRemoveFromUserProfiles(userId, [seriesId], []); + await unitOfWork.CommitAsync(); + } + + public async Task AddProfileToLibrary(int userId, int profileId, int libraryId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + await DeleteImplicitAndRemoveFromUserProfiles(userId, [], [libraryId]); + + profile.LibraryIds.Add(libraryId); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + await unitOfWork.CommitAsync(); + } + + public async Task ClearLibraryProfile(int userId, int libraryId) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var libraryProfile = profiles.FirstOrDefault(p => p.LibraryIds.Contains(libraryId)); + if (libraryProfile != null) + { + libraryProfile.LibraryIds.Remove(libraryId); + unitOfWork.AppUserReadingProfileRepository.Update(libraryProfile); + } + + + if (unitOfWork.HasChanges()) + { + await unitOfWork.CommitAsync(); + } + } + + public async Task GetReadingProfileDtoForLibrary(int userId, int libraryId) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, true); + return mapper.Map(profiles.FirstOrDefault(p => p.LibraryIds.Contains(libraryId))); + } + + private async Task DeleteImplicitAndRemoveFromUserProfiles(int userId, IList seriesIds, IList libraryIds) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var implicitProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any()) + .Where(rp => rp.Kind == ReadingProfileKind.Implicit) + .ToList(); + unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles); + + var nonImplicitProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any() || rp.LibraryIds.Intersect(libraryIds).Any()) + .Where(rp => rp.Kind != ReadingProfileKind.Implicit); + + foreach (var profile in nonImplicitProfiles) + { + profile.SeriesIds.RemoveAll(seriesIds.Contains); + profile.LibraryIds.RemoveAll(libraryIds.Contains); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + } + } + + private async Task DeleteImplicateReadingProfilesForSeries(int userId, IList seriesIds) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var implicitProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any()) + .Where(rp => rp.Kind == ReadingProfileKind.Implicit) + .ToList(); + unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles); + } + + private async Task RemoveSeriesFromUserProfiles(int userId, IList seriesIds) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var userProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any()) + .Where(rp => rp.Kind == ReadingProfileKind.User) + .ToList(); + + unitOfWork.AppUserReadingProfileRepository.RemoveRange(userProfiles); + } + + public static void UpdateReaderProfileFields(AppUserReadingProfile existingProfile, UserReadingProfileDto dto, bool updateName = true) + { + if (updateName && !string.IsNullOrEmpty(dto.Name) && existingProfile.NormalizedName != dto.Name.ToNormalized()) + { + existingProfile.Name = dto.Name; + existingProfile.NormalizedName = dto.Name.ToNormalized(); + } + + // Manga Reader + existingProfile.ReadingDirection = dto.ReadingDirection; + existingProfile.ScalingOption = dto.ScalingOption; + existingProfile.PageSplitOption = dto.PageSplitOption; + existingProfile.ReaderMode = dto.ReaderMode; + existingProfile.AutoCloseMenu = dto.AutoCloseMenu; + existingProfile.ShowScreenHints = dto.ShowScreenHints; + existingProfile.EmulateBook = dto.EmulateBook; + existingProfile.LayoutMode = dto.LayoutMode; + existingProfile.BackgroundColor = string.IsNullOrEmpty(dto.BackgroundColor) ? "#000000" : dto.BackgroundColor; + existingProfile.SwipeToPaginate = dto.SwipeToPaginate; + existingProfile.AllowAutomaticWebtoonReaderDetection = dto.AllowAutomaticWebtoonReaderDetection; + existingProfile.WidthOverride = dto.WidthOverride; + existingProfile.DisableWidthOverride = dto.DisableWidthOverride; + + // Book Reader + existingProfile.BookReaderMargin = dto.BookReaderMargin; + existingProfile.BookReaderLineSpacing = dto.BookReaderLineSpacing; + existingProfile.BookReaderFontSize = dto.BookReaderFontSize; + existingProfile.BookReaderFontFamily = dto.BookReaderFontFamily; + existingProfile.BookReaderTapToPaginate = dto.BookReaderTapToPaginate; + existingProfile.BookReaderReadingDirection = dto.BookReaderReadingDirection; + existingProfile.BookReaderWritingStyle = dto.BookReaderWritingStyle; + existingProfile.BookThemeName = dto.BookReaderThemeName; + existingProfile.BookReaderLayoutMode = dto.BookReaderLayoutMode; + existingProfile.BookReaderImmersiveMode = dto.BookReaderImmersiveMode; + + // PDF Reading + existingProfile.PdfTheme = dto.PdfTheme; + existingProfile.PdfScrollMode = dto.PdfScrollMode; + existingProfile.PdfSpreadMode = dto.PdfSpreadMode; + } +} diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 7d4a4b95a..78e3c41f1 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -1,27 +1,26 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; -using API.Constants; -using API.Controllers; using API.Data; using API.Data.Repositories; using API.DTOs; -using API.DTOs.CollectionTags; +using API.DTOs.Person; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using API.SignalR; -using EasyCaching.Core; using Hangfire; using Kavita.Common; using Microsoft.Extensions.Logging; @@ -33,17 +32,16 @@ public interface ISeriesService { Task GetSeriesDetail(int seriesId, int userId); Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto); - Task UpdateRating(AppUser user, UpdateSeriesRatingDto updateSeriesRatingDto); Task DeleteMultipleSeries(IList seriesIds); Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto); Task GetRelatedSeries(int userId, int seriesId); Task FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true); Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true); - Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, bool withHash); Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false); Task GetEstimatedChapterCreationDate(int seriesId, int userId); + } public class SeriesService : ISeriesService @@ -54,6 +52,7 @@ public class SeriesService : ISeriesService private readonly ILogger _logger; private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; + private readonly IReadingListService _readingListService; private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto { @@ -63,7 +62,8 @@ public class SeriesService : ISeriesService }; public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, - ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService) + ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService, + IReadingListService readingListService) { _unitOfWork = unitOfWork; _eventHub = eventHub; @@ -71,10 +71,11 @@ public class SeriesService : ISeriesService _logger = logger; _scrobblingService = scrobblingService; _localizationService = localizationService; + _readingListService = readingListService; } /// - /// Returns the first chapter for a series to extract metadata from (ie Summary, etc) + /// Returns the first chapter for a series to extract metadata from (ie Summary, etc.) /// /// The full series with all volumes and chapters on it /// @@ -117,33 +118,31 @@ public class SeriesService : ISeriesService series.Metadata ??= new SeriesMetadataBuilder() .Build(); - if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating) - { - series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata.AgeRating; - series.Metadata.AgeRatingLocked = true; - } - if (NumberHelper.IsValidYear(updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) { series.Metadata.ReleaseYear = updateSeriesMetadataDto.SeriesMetadata.ReleaseYear; series.Metadata.ReleaseYearLocked = true; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.StartDate); } if (series.Metadata.PublicationStatus != updateSeriesMetadataDto.SeriesMetadata.PublicationStatus) { series.Metadata.PublicationStatus = updateSeriesMetadataDto.SeriesMetadata.PublicationStatus; series.Metadata.PublicationStatusLocked = true; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.PublicationStatus); } if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata.Summary)) { updateSeriesMetadataDto.SeriesMetadata.Summary = string.Empty; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Summary); } if (series.Metadata.Summary != updateSeriesMetadataDto.SeriesMetadata.Summary.Trim()) { series.Metadata.Summary = updateSeriesMetadataDto.SeriesMetadata?.Summary.Trim() ?? string.Empty; series.Metadata.SummaryLocked = true; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Summary); } if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata?.Language) @@ -169,7 +168,7 @@ public class SeriesService : ISeriesService updateSeriesMetadataDto.SeriesMetadata.Genres.Count != 0) { var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(updateSeriesMetadataDto.SeriesMetadata.Genres.Select(t => Parser.Normalize(t.Title)))).ToList(); - series.Metadata.Genres ??= new List(); + series.Metadata.Genres ??= []; GenreHelper.UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, genre => { series.Metadata.Genres.Add(genre); @@ -177,7 +176,7 @@ public class SeriesService : ISeriesService } else { - series.Metadata.Genres = new List(); + series.Metadata.Genres = []; } @@ -186,7 +185,7 @@ public class SeriesService : ISeriesService var allTags = (await _unitOfWork.TagRepository .GetAllTagsByNameAsync(updateSeriesMetadataDto.SeriesMetadata.Tags.Select(t => Parser.Normalize(t.Title)))) .ToList(); - series.Metadata.Tags ??= new List(); + series.Metadata.Tags ??= []; TagHelper.UpdateTagList(updateSeriesMetadataDto.SeriesMetadata?.Tags, series, allTags, tag => { series.Metadata.Tags.Add(tag); @@ -194,87 +193,112 @@ public class SeriesService : ISeriesService } else { - series.Metadata.Tags = new List(); + series.Metadata.Tags = []; } + if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata?.AgeRating) + { + series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata?.AgeRating ?? AgeRating.Unknown; + series.Metadata.AgeRatingLocked = true; + await _readingListService.UpdateReadingListAgeRatingForSeries(series.Id, series.Metadata.AgeRating); + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating); + } + else + { + if (!series.Metadata.AgeRatingLocked) + { + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); + var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); + if (updatedRating > series.Metadata.AgeRating) + { + series.Metadata.AgeRating = updatedRating; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating); + } + } + } + + // Update people and locks if (updateSeriesMetadataDto.SeriesMetadata != null) { - if (PersonHelper.HasAnyPeople(updateSeriesMetadataDto.SeriesMetadata)) + series.Metadata.People ??= []; + + // Writers + if (!series.Metadata.WriterLocked || !updateSeriesMetadataDto.SeriesMetadata.WriterLocked) { - series.Metadata.People ??= new List(); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer, _unitOfWork); + } - // Writers - if (!series.Metadata.WriterLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer); - } + // Cover Artists + if (!series.Metadata.CoverArtistLocked || !updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist, _unitOfWork); + } - // Cover Artists - if (!series.Metadata.CoverArtistLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist); - } + // Colorists + if (!series.Metadata.ColoristLocked || !updateSeriesMetadataDto.SeriesMetadata.ColoristLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist, _unitOfWork); + } - // Colorists - if (!series.Metadata.ColoristLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist); - } + // Editors + if (!series.Metadata.EditorLocked || !updateSeriesMetadataDto.SeriesMetadata.EditorLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor, _unitOfWork); + } - // Editors - if (!series.Metadata.EditorLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor); - } + // Inkers + if (!series.Metadata.InkerLocked || !updateSeriesMetadataDto.SeriesMetadata.InkerLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker, _unitOfWork); + } - // Inkers - if (!series.Metadata.InkerLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker); - } + // Letterers + if (!series.Metadata.LettererLocked || !updateSeriesMetadataDto.SeriesMetadata.LettererLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer, _unitOfWork); + } - // Letterers - if (!series.Metadata.LettererLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer); - } + // Pencillers + if (!series.Metadata.PencillerLocked || !updateSeriesMetadataDto.SeriesMetadata.PencillerLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller, _unitOfWork); + } - // Pencillers - if (!series.Metadata.PencillerLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller); - } + // Publishers + if (!series.Metadata.PublisherLocked || !updateSeriesMetadataDto.SeriesMetadata.PublisherLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher, _unitOfWork); + } - // Publishers - if (!series.Metadata.PublisherLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher); - } + // Imprints + if (!series.Metadata.ImprintLocked || !updateSeriesMetadataDto.SeriesMetadata.ImprintLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint, _unitOfWork); + } - // Imprints - if (!series.Metadata.ImprintLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint); - } + // Teams + if (!series.Metadata.TeamLocked || !updateSeriesMetadataDto.SeriesMetadata.TeamLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team, _unitOfWork); + } - // Teams - if (!series.Metadata.TeamLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team); - } + // Locations + if (!series.Metadata.LocationLocked || !updateSeriesMetadataDto.SeriesMetadata.LocationLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location, _unitOfWork); + } - // Locations - if (!series.Metadata.LocationLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location); - } - - // Translators - if (!series.Metadata.TranslatorLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator); - } + // Translators + if (!series.Metadata.TranslatorLocked || !updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator, _unitOfWork); + } + // Characters + if (!series.Metadata.CharacterLocked || !updateSeriesMetadataDto.SeriesMetadata.CharacterLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Characters, PersonRole.Character, _unitOfWork); } series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked; @@ -291,6 +315,7 @@ public class SeriesService : ISeriesService series.Metadata.PencillerLocked = updateSeriesMetadataDto.SeriesMetadata.PencillerLocked; series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublisherLocked; series.Metadata.TranslatorLocked = updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked; + series.Metadata.LocationLocked = updateSeriesMetadataDto.SeriesMetadata.LocationLocked; series.Metadata.CoverArtistLocked = updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked; series.Metadata.WriterLocked = updateSeriesMetadataDto.SeriesMetadata.WriterLocked; series.Metadata.SummaryLocked = updateSeriesMetadataDto.SeriesMetadata.SummaryLocked; @@ -302,9 +327,10 @@ public class SeriesService : ISeriesService return true; } + _unitOfWork.SeriesRepository.Update(series.Metadata); await _unitOfWork.CommitAsync(); - // Trigger code to cleanup tags, collections, people, etc + // Trigger code to clean up tags, collections, people, etc try { await _taskScheduler.CleanupDbEntries(); @@ -331,8 +357,10 @@ public class SeriesService : ISeriesService /// /// /// - private async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection peopleDtos, PersonRole role) + public static async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection peopleDtos, PersonRole role, IUnitOfWork unitOfWork) { + // TODO: Cleanup this code so we aren't using UnitOfWork like this + // Normalize all names from the DTOs var normalizedNames = peopleDtos .Select(p => Parser.Normalize(p.Name)) @@ -340,10 +368,10 @@ public class SeriesService : ISeriesService .ToList(); // Bulk select people who already exist in the database - var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); + var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); // Use a dictionary for quick lookups - var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName).ToDictionary(p => p.NormalizedName, p => p); + var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople); // List to track people that will be added to the metadata var peopleToAdd = new List(); @@ -353,13 +381,28 @@ public class SeriesService : ISeriesService var normalizedPersonName = Parser.Normalize(personDto.Name); // Check if the person exists in the dictionary - if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out _)) continue; + if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p)) + { + // TODO: Should I add more controls here to map back? + if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId) + { + p.AniListId = personDto.AniListId; + } + p.Description = string.IsNullOrEmpty(p.Description) ? personDto.Description : p.Description; + continue; // If we ever want to update metadata for existing people, we'd do it here + } // Person doesn't exist, so create a new one var newPerson = new Person { Name = personDto.Name, - NormalizedName = normalizedPersonName + NormalizedName = normalizedPersonName, + AniListId = personDto.AniListId, + Description = personDto.Description, + Asin = personDto.Asin, + CoverImage = personDto.CoverImage, + MalId = personDto.MalId, + HardcoverId = personDto.HardcoverId, }; peopleToAdd.Add(newPerson); @@ -369,7 +412,7 @@ public class SeriesService : ISeriesService // Add any new people to the database in bulk if (peopleToAdd.Count != 0) { - _unitOfWork.PersonRepository.Attach(peopleToAdd); + unitOfWork.PersonRepository.Attach(peopleToAdd); } // Now that we have all the people (new and existing), update the SeriesMetadataPeople @@ -411,63 +454,12 @@ public class SeriesService : ISeriesService } - - /// - /// - /// - /// User with Ratings includes - /// - /// - public async Task UpdateRating(AppUser? user, UpdateSeriesRatingDto updateSeriesRatingDto) - { - if (user == null) - { - _logger.LogError("Cannot update rating of null user"); - return false; - } - - var userRating = - await _unitOfWork.UserRepository.GetUserRatingAsync(updateSeriesRatingDto.SeriesId, user.Id) ?? - new AppUserRating(); - try - { - userRating.Rating = Math.Clamp(updateSeriesRatingDto.UserRating, 0f, 5f); - userRating.HasBeenRated = true; - userRating.SeriesId = updateSeriesRatingDto.SeriesId; - - if (userRating.Id == 0) - { - user.Ratings ??= new List(); - user.Ratings.Add(userRating); - } - - _unitOfWork.UserRepository.Update(user); - - if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) - { - BackgroundJob.Enqueue(() => - _scrobblingService.ScrobbleRatingUpdate(user.Id, updateSeriesRatingDto.SeriesId, - userRating.Rating)); - return true; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception saving rating"); - } - - await _unitOfWork.RollbackAsync(); - user.Ratings?.Remove(userRating); - - return false; - } - public async Task DeleteMultipleSeries(IList seriesIds) { try { var chapterMappings = - await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(seriesIds.ToArray()); + await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync([.. seriesIds]); var allChapterIds = new List(); foreach (var mapping in chapterMappings) @@ -475,9 +467,8 @@ public class SeriesService : ISeriesService allChapterIds.AddRange(mapping.Value); } - // NOTE: This isn't getting all the people and whatnot currently + // NOTE: This isn't getting all the people and whatnot currently due to the lack of includes var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(seriesIds); - _unitOfWork.SeriesRepository.Remove(series); var libraryIds = series.Select(s => s.LibraryId); @@ -498,7 +489,8 @@ public class SeriesService : ISeriesService await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); - _taskScheduler.CleanupChapters(allChapterIds.ToArray()); + _taskScheduler.CleanupChapters([.. allChapterIds]); + return true; } catch (Exception ex) @@ -877,19 +869,19 @@ public class SeriesService : ISeriesService // Calculate the time differences between consecutive chapters var timeDifferences = new List(); DateTime? previousChapterTime = null; - foreach (var chapter in chapters) + foreach (var chapterCreatedUtc in chapters.Select(c => c.CreatedUtc)) { - if (previousChapterTime.HasValue && (chapter.CreatedUtc - previousChapterTime.Value) <= TimeSpan.FromHours(1)) + if (previousChapterTime.HasValue && (chapterCreatedUtc - previousChapterTime.Value) <= TimeSpan.FromHours(1)) { continue; // Skip this chapter if it's within an hour of the previous one } - if ((chapter.CreatedUtc - previousChapterTime ?? TimeSpan.Zero) != TimeSpan.Zero) + if ((chapterCreatedUtc - previousChapterTime ?? TimeSpan.Zero) != TimeSpan.Zero) { - timeDifferences.Add(chapter.CreatedUtc - previousChapterTime ?? TimeSpan.Zero); + timeDifferences.Add(chapterCreatedUtc - previousChapterTime ?? TimeSpan.Zero); } - previousChapterTime = chapter.CreatedUtc; + previousChapterTime = chapterCreatedUtc; } if (timeDifferences.Count < minimumTimeDeltas) diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs new file mode 100644 index 000000000..fd44b5962 --- /dev/null +++ b/API/Services/SettingsService.cs @@ -0,0 +1,447 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Settings; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Logging; +using API.Services.Tasks.Scanner; +using Hangfire; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface ISettingsService +{ + Task UpdateMetadataSettings(MetadataSettingsDto dto); + Task UpdateSettings(ServerSettingDto updateSettingsDto); +} + + +public class SettingsService : ISettingsService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IDirectoryService _directoryService; + private readonly ILibraryWatcher _libraryWatcher; + private readonly ITaskScheduler _taskScheduler; + private readonly ILogger _logger; + + public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService, + ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler, + ILogger logger) + { + _unitOfWork = unitOfWork; + _directoryService = directoryService; + _libraryWatcher = libraryWatcher; + _taskScheduler = taskScheduler; + _logger = logger; + } + + /// + /// Update the metadata settings for Kavita+ Metadata feature + /// + /// + /// + public async Task UpdateMetadataSettings(MetadataSettingsDto dto) + { + var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + existingMetadataSetting.Enabled = dto.Enabled; + existingMetadataSetting.EnableSummary = dto.EnableSummary; + existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName; + existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus; + existingMetadataSetting.EnableRelationships = dto.EnableRelationships; + existingMetadataSetting.EnablePeople = dto.EnablePeople; + existingMetadataSetting.EnableStartDate = dto.EnableStartDate; + existingMetadataSetting.EnableGenres = dto.EnableGenres; + existingMetadataSetting.EnableTags = dto.EnableTags; + existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming; + existingMetadataSetting.EnableCoverImage = dto.EnableCoverImage; + + existingMetadataSetting.EnableChapterPublisher = dto.EnableChapterPublisher; + existingMetadataSetting.EnableChapterSummary = dto.EnableChapterSummary; + existingMetadataSetting.EnableChapterTitle = dto.EnableChapterTitle; + existingMetadataSetting.EnableChapterReleaseDate = dto.EnableChapterReleaseDate; + existingMetadataSetting.EnableChapterCoverImage = dto.EnableChapterCoverImage; + + existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? []; + + existingMetadataSetting.Blacklist = (dto.Blacklist ?? []).Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; + existingMetadataSetting.Whitelist = (dto.Whitelist ?? []).Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; + existingMetadataSetting.Overrides = [.. dto.Overrides ?? []]; + existingMetadataSetting.PersonRoles = dto.PersonRoles ?? []; + + // Handle Field Mappings + + // Clear existing mappings + existingMetadataSetting.FieldMappings ??= []; + _unitOfWork.SettingsRepository.RemoveRange(existingMetadataSetting.FieldMappings); + existingMetadataSetting.FieldMappings.Clear(); + + if (dto.FieldMappings != null) + { + // Add new mappings + foreach (var mappingDto in dto.FieldMappings) + { + existingMetadataSetting.FieldMappings.Add(new MetadataFieldMapping + { + SourceType = mappingDto.SourceType, + DestinationType = mappingDto.DestinationType, + SourceValue = mappingDto.SourceValue, + DestinationValue = mappingDto.DestinationValue, + ExcludeFromSource = mappingDto.ExcludeFromSource + }); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + + // Return updated settings + return await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + } + + /// + /// Update Server Settings + /// + /// + /// + /// + public async Task UpdateSettings(ServerSettingDto updateSettingsDto) + { + // We do not allow CacheDirectory changes, so we will ignore. + var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); + var updateBookmarks = false; + var originalBookmarkDirectory = _directoryService.BookmarkDirectory; + + var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; + if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && + !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) + { + bookmarkDirectory = + _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); + } + + if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) + { + bookmarkDirectory = _directoryService.BookmarkDirectory; + } + + var updateTask = false; + foreach (var setting in currentSettings) + { + if (setting.Key == ServerSettingKey.OnDeckProgressDays && + updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.OnDeckUpdateDays && + updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) + { + if (OsInfo.IsDocker) continue; + setting.Value = updateSettingsDto.Port + string.Empty; + // Port is managed in appSetting.json + Configuration.Port = updateSettingsDto.Port; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.CacheSize && + updateSettingsDto.CacheSize + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.CacheSize + string.Empty; + // CacheSize is managed in appSetting.json + Configuration.CacheSize = updateSettingsDto.CacheSize; + _unitOfWork.SettingsRepository.Update(setting); + } + + updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); + + UpdateEmailSettings(setting, updateSettingsDto); + + + + if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) + { + if (OsInfo.IsDocker) continue; + // Validate IP addresses + foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (!IPAddress.TryParse(ipAddress.Trim(), out _)) + { + throw new KavitaException("ip-address-invalid"); + } + } + + setting.Value = updateSettingsDto.IpAddresses; + // IpAddresses is managed in appSetting.json + Configuration.IpAddresses = updateSettingsDto.IpAddresses; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) + { + var path = !updateSettingsDto.BaseUrl.StartsWith('/') + ? $"/{updateSettingsDto.BaseUrl}" + : updateSettingsDto.BaseUrl; + path = !path.EndsWith('/') + ? $"{path}/" + : path; + setting.Value = path; + Configuration.BaseUrl = updateSettingsDto.BaseUrl; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.LoggingLevel && + updateSettingsDto.LoggingLevel + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.LoggingLevel + string.Empty; + LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EnableOpds && + updateSettingsDto.EnableOpds + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableOpds + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EncodeMediaAs && + ((int)updateSettingsDto.EncodeMediaAs).ToString() != setting.Value) + { + setting.Value = ((int)updateSettingsDto.EncodeMediaAs).ToString(); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.CoverImageSize && + ((int)updateSettingsDto.CoverImageSize).ToString() != setting.Value) + { + setting.Value = ((int)updateSettingsDto.CoverImageSize).ToString(); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value) + { + setting.Value = (updateSettingsDto.HostName + string.Empty).Trim(); + setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) + { + // Validate new directory can be used + if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) + { + throw new KavitaException("bookmark-dir-permissions"); + } + + originalBookmarkDirectory = setting.Value; + + // Normalize the path deliminators. Just to look nice in DB, no functionality + setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); + _unitOfWork.SettingsRepository.Update(setting); + updateBookmarks = true; + + } + + if (setting.Key == ServerSettingKey.AllowStatCollection && + updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TotalBackups && + updateSettingsDto.TotalBackups + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) + { + throw new KavitaException("total-backups"); + } + + setting.Value = updateSettingsDto.TotalBackups + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TotalLogs && + updateSettingsDto.TotalLogs + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1) + { + throw new KavitaException("total-logs"); + } + + setting.Value = updateSettingsDto.TotalLogs + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EnableFolderWatching && + updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } + + if (!_unitOfWork.HasChanges()) return updateSettingsDto; + + try + { + await _unitOfWork.CommitAsync(); + + if (!updateSettingsDto.AllowStatCollection) + { + _taskScheduler.CancelStatsTasks(); + } + else + { + await _taskScheduler.ScheduleStatsTasks(); + } + + if (updateBookmarks) + { + UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory); + } + + if (updateTask) + { + BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); + } + + if (updateSettingsDto.EnableFolderWatching) + { + BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); + } + else + { + BackgroundJob.Enqueue(() => _libraryWatcher.StopWatching()); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when updating server settings"); + await _unitOfWork.RollbackAsync(); + throw new KavitaException("generic-error"); + } + + + _logger.LogInformation("Server Settings updated"); + + return updateSettingsDto; + } + + private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) + { + _directoryService.ExistOrCreate(bookmarkDirectory); + _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); + _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); + } + + private bool UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) + { + setting.Value = updateSettingsDto.TaskBackup; + _unitOfWork.SettingsRepository.Update(setting); + + return true; + } + + if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) + { + setting.Value = updateSettingsDto.TaskScan; + _unitOfWork.SettingsRepository.Update(setting); + return true; + } + + if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) + { + setting.Value = updateSettingsDto.TaskCleanup; + _unitOfWork.SettingsRepository.Update(setting); + return true; + } + return false; + } + + private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.EmailHost && + updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailPort && + updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthPassword && + updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthUserName && + updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderAddress && + updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderDisplayName && + updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSizeLimit && + updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailEnableSsl && + updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && + updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } +} diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 8fa22cc2f..006bad184 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -36,7 +36,6 @@ public interface IStatisticService IEnumerable> GetWordsReadCountByYear(int userId = 0); Task UpdateServerStatistics(); Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds); - Task GetKavitaPlusMetadataBreakdown(); Task> GetFilesByExtension(string fileExtension); } @@ -89,7 +88,9 @@ public class StatisticService : IStatisticService var lastActive = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) - .MaxAsync(p => p.LastModified); + .Select(p => p.LastModified) + .DefaultIfEmpty() + .MaxAsync(); // First get the total pages per library @@ -127,12 +128,25 @@ public class StatisticService : IStatisticService var earliestReadDate = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) - .MinAsync(p => p.Created); + .Select(p => p.Created) + .DefaultIfEmpty() + .MinAsync(); + + if (earliestReadDate == DateTime.MinValue) + { + averageReadingTimePerWeek = 0; + } + else + { +#pragma warning disable S6561 + var timeDifference = DateTime.Now - earliestReadDate; +#pragma warning restore S6561 + var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7); + + averageReadingTimePerWeek /= deltaWeeks; + } - var timeDifference = DateTime.Now - earliestReadDate; - var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7); - averageReadingTimePerWeek /= deltaWeeks; return new UserReadStatistics() @@ -344,6 +358,7 @@ public class StatisticService : IStatisticService SeriesId = u.SeriesId, LibraryId = u.LibraryId, ReadDate = u.LastModified, + ReadDateUtc = u.LastModifiedUtc, ChapterId = u.ChapterId, ChapterNumber = _context.Chapter.Single(c => c.Id == u.ChapterId).MinNumber }) @@ -540,29 +555,6 @@ public class StatisticService : IStatisticService p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))); } - public async Task GetKavitaPlusMetadataBreakdown() - { - // We need to count number of Series that have an external series record - // Then count how many series are blacklisted - // Then get total count of series that are Kavita+ eligible - var plusLibraries = await _context.Library - .Where(l => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(l.Type)) - .Select(l => l.Id) - .ToListAsync(); - - var countOfBlacklisted = await _context.SeriesBlacklist.CountAsync(); - var totalSeries = await _context.Series.Where(s => plusLibraries.Contains(s.LibraryId)).CountAsync(); - var seriesWithMetadata = await _context.ExternalSeriesMetadata.CountAsync(); - - return new KavitaPlusMetadataBreakdownDto() - { - TotalSeries = totalSeries, - ErroredSeries = countOfBlacklisted, - SeriesCompleted = seriesWithMetadata - }; - - } - public async Task> GetFilesByExtension(string fileExtension) { var query = _context.MangaFile diff --git a/API/Services/StreamService.cs b/API/Services/StreamService.cs index f12f10a8a..1f2e55579 100644 --- a/API/Services/StreamService.cs +++ b/API/Services/StreamService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -11,6 +12,7 @@ using API.Helpers; using API.SignalR; using Kavita.Common; using Kavita.Common.Helpers; +using Microsoft.Extensions.Logging; namespace API.Services; @@ -33,6 +35,9 @@ public interface IStreamService Task CreateExternalSource(int userId, ExternalSourceDto dto); Task UpdateExternalSource(int userId, ExternalSourceDto dto); Task DeleteExternalSource(int userId, int externalSourceId); + Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId); + Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId); + Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter); } public class StreamService : IStreamService @@ -40,12 +45,14 @@ public class StreamService : IStreamService private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; - public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService) + public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService, ILogger logger) { _unitOfWork = unitOfWork; _eventHub = eventHub; _localizationService = localizationService; + _logger = logger; } public async Task> GetDashboardStreams(int userId, bool visibleOnly = true) @@ -91,6 +98,7 @@ public class StreamService : IStreamService var ret = new DashboardStreamDto() { + Id = createdStream.Id, Name = createdStream.Name, IsProvided = createdStream.IsProvided, Visible = createdStream.Visible, @@ -123,7 +131,10 @@ public class StreamService : IStreamService AppUserIncludes.DashboardStreams); var stream = user?.DashboardStreams.FirstOrDefault(d => d.Id == dto.Id); if (stream == null) + { throw new KavitaException(await _localizationService.Translate(userId, "dashboard-stream-doesnt-exist")); + } + if (stream.Order == dto.ToPosition) return; var list = user!.DashboardStreams.OrderBy(s => s.Order).ToList(); @@ -179,6 +190,7 @@ public class StreamService : IStreamService var ret = new SideNavStreamDto() { + Id = createdStream.Id, Name = createdStream.Name, IsProvided = createdStream.IsProvided, Visible = createdStream.Visible, @@ -341,4 +353,72 @@ public class StreamService : IStreamService await _unitOfWork.CommitAsync(); } + + public async Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId) + { + try + { + var stream = await _unitOfWork.UserRepository.GetSideNavStream(sideNavStreamId); + if (stream == null) throw new KavitaException("sidenav-stream-doesnt-exist"); + + if (stream.AppUserId != userId) throw new KavitaException("sidenav-stream-doesnt-exist"); + + + if (stream.StreamType != SideNavStreamType.SmartFilter) + { + throw new KavitaException("sidenav-stream-only-delete-smart-filter"); + } + + _unitOfWork.UserRepository.Delete(stream); + + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception deleting SideNav Smart Filter Stream: {FilterId}", sideNavStreamId); + throw; + } + } + + public async Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId) + { + try + { + var stream = await _unitOfWork.UserRepository.GetDashboardStream(dashboardStreamId); + if (stream == null) throw new KavitaException("dashboard-stream-doesnt-exist"); + + if (stream.AppUserId != userId) throw new KavitaException("dashboard-stream-doesnt-exist"); + + if (stream.StreamType != DashboardStreamType.SmartFilter) + { + throw new KavitaException("dashboard-stream-only-delete-smart-filter"); + } + + _unitOfWork.UserRepository.Delete(stream); + + await _unitOfWork.CommitAsync(); + } catch (Exception ex) + { + _logger.LogError(ex, "There was an exception deleting Dashboard Smart Filter Stream: {FilterId}", dashboardStreamId); + throw; + } + } + + public async Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter) + { + var sideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreamWithFilter(smartFilter.Id); + var dashboardStreams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(smartFilter.Id); + + foreach (var sideNavStream in sideNavStreams) + { + sideNavStream.Name = smartFilter.Name; + } + + foreach (var dashboardStream in dashboardStreams) + { + dashboardStream.Name = smartFilter.Name; + } + + await _unitOfWork.CommitAsync(); + } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 2dbd4ed34..575f89b3b 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -6,6 +6,8 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.Entities.Enums; +using API.Extensions; +using API.Helpers; using API.Helpers.Converters; using API.Services.Plus; using API.Services.Tasks; @@ -32,7 +34,6 @@ public interface ITaskScheduler void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false, bool forceColorscape = false); Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false); - void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false); void CancelStatsTasks(); Task RunStatCollection(); void CovertAllCoversToEncoding(); @@ -60,6 +61,7 @@ public class TaskScheduler : ITaskScheduler private readonly ILicenseService _licenseService; private readonly IExternalMetadataService _externalMetadataService; private readonly ISmartCollectionSyncService _smartCollectionSyncService; + private readonly IWantToReadSyncService _wantToReadSyncService; private readonly IEventHub _eventHub; public static BackgroundJobServer Client => new (); @@ -80,6 +82,7 @@ public class TaskScheduler : ITaskScheduler public const string LicenseCheckId = "license-check"; public const string KavitaPlusDataRefreshId = "kavita+-data-refresh"; public const string KavitaPlusStackSyncId = "kavita+-stack-sync"; + public const string KavitaPlusWantToReadSyncId = "kavita+-want-to-read-sync"; public static readonly ImmutableArray ScanTasks = ["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"]; @@ -98,7 +101,8 @@ public class TaskScheduler : ITaskScheduler ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService, - IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub) + IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, + IWantToReadSyncService wantToReadSyncService, IEventHub eventHub) { _cacheService = cacheService; _logger = logger; @@ -117,6 +121,7 @@ public class TaskScheduler : ITaskScheduler _licenseService = licenseService; _externalMetadataService = externalMetadataService; _smartCollectionSyncService = smartCollectionSyncService; + _wantToReadSyncService = wantToReadSyncService; _eventHub = eventHub; } @@ -204,23 +209,45 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(CheckScrobblingTokensId, () => _scrobblingService.CheckExternalAccessTokens(), Cron.Daily, RecurringJobOptions); BackgroundJob.Enqueue(() => _scrobblingService.CheckExternalAccessTokens()); // We also kick off an immediate check on startup - RecurringJob.AddOrUpdate(LicenseCheckId, () => _licenseService.HasActiveLicense(true), + + // Get the License Info (and cache it) on first load. This will internally cache the Github releases for the Version Service + await _licenseService.GetLicenseInfo(true); // Kick this off first to cache it then let it refresh every 9 hours (8 hour cache) + RecurringJob.AddOrUpdate(LicenseCheckId, () => _licenseService.GetLicenseInfo(false), LicenseService.Cron, RecurringJobOptions); - // KavitaPlus Scrobbling (every 4 hours) + // KavitaPlus Scrobbling (every hour) - randomise minutes to spread requests out for K+ RecurringJob.AddOrUpdate(ProcessScrobblingEventsId, () => _scrobblingService.ProcessUpdatesSinceLastSync(), - "0 */4 * * *", RecurringJobOptions); + Cron.Hourly(Rnd.Next(0, 60)), RecurringJobOptions); RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(), Cron.Daily, RecurringJobOptions); // Backfilling/Freshening Reviews/Rating/Recommendations RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId, - () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 4)), + () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 5)), RecurringJobOptions); + // This shouldn't be so close to fetching data due to Rate limit concerns RecurringJob.AddOrUpdate(KavitaPlusStackSyncId, - () => _smartCollectionSyncService.Sync(), Cron.Daily(Rnd.Next(1, 4)), + () => _smartCollectionSyncService.Sync(), Cron.Daily(Rnd.Next(6, 10)), RecurringJobOptions); + + RecurringJob.AddOrUpdate(KavitaPlusWantToReadSyncId, + () => _wantToReadSyncService.Sync(), Cron.Weekly(DayOfWeekHelper.Random()), + RecurringJobOptions); + } + + /// + /// Removes any Kavita+ Recurring Jobs + /// + public static void RemoveKavitaPlusTasks() + { + RecurringJob.RemoveIfExists(CheckScrobblingTokensId); + RecurringJob.RemoveIfExists(LicenseCheckId); + RecurringJob.RemoveIfExists(ProcessScrobblingEventsId); + RecurringJob.RemoveIfExists(ProcessProcessedScrobblingEventsId); + RecurringJob.RemoveIfExists(KavitaPlusDataRefreshId); + RecurringJob.RemoveIfExists(KavitaPlusStackSyncId); + RecurringJob.RemoveIfExists(KavitaPlusWantToReadSyncId); } #region StatsTasks @@ -239,11 +266,6 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), RecurringJobOptions); } - public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false) - { - _logger.LogInformation("Enqueuing library file analysis for: {LibraryId}", libraryId); - BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanLibrary(libraryId, forceUpdate)); - } /// /// Upon cancelling stat, we do report to the Stat service that we are no longer going to be reporting @@ -307,7 +329,7 @@ public class TaskScheduler : ITaskScheduler if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, normalizedOriginal]) || HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } @@ -324,7 +346,7 @@ public class TaskScheduler : ITaskScheduler var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 4faf59e6c..e39600c3f 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -8,8 +8,10 @@ using API.DTOs.Filtering; using API.Entities; using API.Entities.Enums; using API.Helpers; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Hangfire; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Tasks; @@ -33,6 +35,11 @@ public interface ICleanupService /// /// Task CleanupWantToRead(); + + Task ConsolidateProgress(); + + Task CleanupMediaErrors(); + } /// /// Cleans up after operations on reoccurring basis @@ -74,13 +81,23 @@ public class CleanupService : ICleanupService _logger.LogInformation("Starting Cleanup"); await SendProgress(0F, "Starting cleanup"); + _logger.LogInformation("Cleaning temp directory"); _directoryService.ClearDirectory(_directoryService.TempDirectory); + await SendProgress(0.1F, "Cleaning temp directory"); CleanupCacheAndTempDirectories(); + await SendProgress(0.25F, "Cleaning old database backups"); _logger.LogInformation("Cleaning old database backups"); await CleanupBackups(); + + await SendProgress(0.35F, "Consolidating Progress Events"); + await ConsolidateProgress(); + + await SendProgress(0.4F, "Consolidating Media Errors"); + await CleanupMediaErrors(); + await SendProgress(0.50F, "Cleaning deleted cover images"); _logger.LogInformation("Cleaning deleted cover images"); await DeleteSeriesCoverImages(); @@ -226,6 +243,108 @@ public class CleanupService : ICleanupService _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); } + /// + /// Find any progress events that have duplicate, find the highest page read event, then copy over information from that and delete others, to leave one. + /// + public async Task ConsolidateProgress() + { + _logger.LogInformation("Consolidating Progress Events"); + // AppUserProgress + var allProgress = await _unitOfWork.AppUserProgressRepository.GetAllProgress(); + + // Group by the unique identifiers that would make a progress entry unique + var duplicateGroups = allProgress + .GroupBy(p => new + { + p.AppUserId, + p.ChapterId, + }) + .Where(g => g.Count() > 1); + + foreach (var group in duplicateGroups) + { + // Find the entry with the highest pages read + var highestProgress = group + .OrderByDescending(p => p.PagesRead) + .ThenByDescending(p => p.LastModifiedUtc) + .First(); + + // Get the duplicate entries to remove (all except the highest progress) + var duplicatesToRemove = group + .Where(p => p.Id != highestProgress.Id) + .ToList(); + + // Copy over any non-null BookScrollId if the highest progress entry doesn't have one + if (string.IsNullOrEmpty(highestProgress.BookScrollId)) + { + var firstValidScrollId = duplicatesToRemove + .FirstOrDefault(p => !string.IsNullOrEmpty(p.BookScrollId)) + ?.BookScrollId; + + if (firstValidScrollId != null) + { + highestProgress.BookScrollId = firstValidScrollId; + highestProgress.MarkModified(); + } + } + + // Remove the duplicates + foreach (var duplicate in duplicatesToRemove) + { + _unitOfWork.AppUserProgressRepository.Remove(duplicate); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + } + + /// + /// Scans through Media Error and removes any entries that have been fixed and are within the DB (proper files where wordcount/pagecount > 0) + /// + public async Task CleanupMediaErrors() + { + try + { + List errorStrings = ["This archive cannot be read or not supported", "File format not supported"]; + var mediaErrors = await _unitOfWork.MediaErrorRepository.GetAllErrorsAsync(errorStrings); + _logger.LogInformation("Beginning consolidation of {Count} Media Errors", mediaErrors.Count); + + var pathToErrorMap = mediaErrors + .GroupBy(me => Parser.NormalizePath(me.FilePath)) + .ToDictionary( + group => group.Key, + group => group.ToList() // The same file can be duplicated (rare issue when network drives die out midscan) + ); + + var normalizedPaths = pathToErrorMap.Keys.ToList(); + + // Find all files that are valid + var validFiles = await _unitOfWork.DataContext.MangaFile + .Where(f => normalizedPaths.Contains(f.FilePath) && f.Pages > 0) + .Select(f => f.FilePath) + .ToListAsync(); + + var removalCount = 0; + foreach (var validFilePath in validFiles) + { + if (!pathToErrorMap.TryGetValue(validFilePath, out var mediaError)) continue; + + _unitOfWork.MediaErrorRepository.Remove(mediaError); + removalCount++; + } + + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Finished consolidation of {Count} Media Errors, Removed: {RemovalCount}", + mediaErrors.Count, removalCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception consolidating media errors"); + } + } + public async Task CleanupLogs() { _logger.LogInformation("Performing cleanup of logs directory"); diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index da83eebf6..015613965 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -4,10 +4,13 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Constants; +using API.Data; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; +using API.Entities.Person; using API.Extensions; +using API.SignalR; using EasyCaching.Core; using Flurl; using Flurl.Http; @@ -17,13 +20,19 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NetVips; + namespace API.Services.Tasks.Metadata; +#nullable enable public interface ICoverDbService { Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat); + Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url); + Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false); + Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false); + Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false); } @@ -33,6 +42,10 @@ public class CoverDbService : ICoverDbService private readonly IDirectoryService _directoryService; private readonly IEasyCachingProviderFactory _cacheFactory; private readonly IHostEnvironment _env; + private readonly IImageService _imageService; + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + private TimeSpan _cacheTime = TimeSpan.FromDays(10); private const string NewHost = "https://www.kavitareader.com/CoversDB/"; @@ -50,16 +63,40 @@ public class CoverDbService : ICoverDbService { ["https://app.plex.tv"] = "https://plex.tv" }; + /// + /// Cache of the publisher/favicon list + /// + private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(1); public CoverDbService(ILogger logger, IDirectoryService directoryService, - IEasyCachingProviderFactory cacheFactory, IHostEnvironment env) + IEasyCachingProviderFactory cacheFactory, IHostEnvironment env, IImageService imageService, + IUnitOfWork unitOfWork, IEventHub eventHub) { _logger = logger; _directoryService = directoryService; _cacheFactory = cacheFactory; _env = env; + _imageService = imageService; + _unitOfWork = unitOfWork; + _eventHub = eventHub; } + /// + /// Downloads the favicon image from a given website URL, optionally falling back to a custom method if standard methods fail. + /// + /// The full URL of the website to extract the favicon from. + /// The desired image encoding format for saving the favicon (e.g., WebP, PNG). + /// + /// A string representing the filename of the downloaded favicon image, saved to the configured favicon directory. + /// + /// + /// Thrown when favicon retrieval fails or if a previously failed domain is detected in cache. + /// + /// + /// This method first checks for a cached failure to avoid re-requesting bad links. + /// It then attempts to parse HTML for `link` tags pointing to `.png` favicons and + /// falls back to an internal fallback method if needed. Valid results are saved to disk. + /// public async Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat) { // Parse the URL to get the domain (including subdomain) @@ -77,7 +114,7 @@ public class CoverDbService : ICoverDbService throw new KavitaException($"Kavita has already tried to fetch from {sanitizedBaseUrl} and failed. Skipping duplicate check"); } - await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10)); + await provider.SetAsync(baseUrl, string.Empty, _cacheTime); if (FaviconUrlMapper.TryGetValue(baseUrl, out var value)) { url = value; @@ -136,23 +173,10 @@ public class CoverDbService : ICoverDbService // Create the destination file path using var image = Image.PngloadStream(faviconStream); var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat); - switch (encodeFormat) - { - case EncodeFormat.PNG: - image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - case EncodeFormat.WEBP: - image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - case EncodeFormat.AVIF: - image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); - } - + image.WriteToFile(Path.Combine(_directoryService.FaviconDirectory, filename)); _logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain); + return filename; } catch (Exception ex) { @@ -165,38 +189,31 @@ public class CoverDbService : ICoverDbService { try { + // Sanitize user input + publisherName = publisherName.Replace(Environment.NewLine, string.Empty).Replace("\r", string.Empty).Replace("\n", string.Empty); + var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Publisher); + var res = await provider.GetAsync(publisherName); + if (res.HasValue) + { + _logger.LogInformation("Kavita has already tried to fetch Publisher: {PublisherName} and failed. Skipping duplicate check", publisherName); + throw new KavitaException($"Kavita has already tried to fetch Publisher: {publisherName} and failed. Skipping duplicate check"); + } + + await provider.SetAsync(publisherName, string.Empty, _cacheTime); var publisherLink = await FallbackToKavitaReaderPublisher(publisherName); if (string.IsNullOrEmpty(publisherLink)) { throw new KavitaException($"Could not grab publisher image for {publisherName}"); } - _logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize()); - // Download the publisher file using Flurl - var publisherStream = await publisherLink - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - // Create the destination file path - using var image = Image.NewFromStream(publisherStream); var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat); - switch (encodeFormat) - { - case EncodeFormat.PNG: - image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename)); - break; - case EncodeFormat.WEBP: - image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename)); - break; - case EncodeFormat.AVIF: - image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename)); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); - } + _logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize()); + await DownloadImageFromUrl(publisherName, encodeFormat, publisherLink, _directoryService.PublisherDirectory); _logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize()); + return filename; } catch (Exception ex) { @@ -220,36 +237,37 @@ public class CoverDbService : ICoverDbService { throw new KavitaException($"Could not grab person image for {person.Name}"); } + return await DownloadPersonImageAsync(person, encodeFormat, personImageLink); + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PersonName}", person.Name); + } - // Create the destination file path - var filename = ImageService.GetPersonFormat(person.Id) + encodeFormat.GetExtension(); - var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename); + return null; + } - // Ensure if file exists, we delete to overwrite - - - _logger.LogTrace("Fetching publisher image from {Url}", personImageLink.Sanitize()); - // Download the publisher file using Flurl - var personStream = await personImageLink - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - - using var image = Image.NewFromStream(personStream); - switch (encodeFormat) + /// + /// Attempts to download the Person cover image from a Url + /// + /// + /// + /// + /// + /// + /// + public async Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url) + { + try + { + var personImageLink = await GetCoverPersonImagePath(person); + if (string.IsNullOrEmpty(personImageLink)) { - case EncodeFormat.PNG: - image.Pngsave(targetFile); - break; - case EncodeFormat.WEBP: - image.Webpsave(targetFile); - break; - case EncodeFormat.AVIF: - image.Heifsave(targetFile); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + throw new KavitaException($"Could not grab person image for {person.Name}"); } + + var filename = await DownloadImageFromUrl(ImageService.GetPersonFormat(person.Id), encodeFormat, personImageLink); + _logger.LogDebug("Person image for {PersonName} downloaded and saved successfully", person.Name); return filename; @@ -261,9 +279,52 @@ public class CoverDbService : ICoverDbService return null; } - private async Task GetCoverPersonImagePath(Person person) + private async Task DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url, string? targetDirectory = null) { - var tempFile = Path.Join(_directoryService.TempDirectory, "people.yml"); + // TODO: I need to unit test this to ensure it works when overwriting, etc + + // Target Directory defaults to CoverImageDirectory, but can be temp for when comparison between images is used + targetDirectory ??= _directoryService.CoverImageDirectory; + + // Create the destination file path + var filename = filenameWithoutExtension + encodeFormat.GetExtension(); + var targetFile = Path.Combine(targetDirectory, filename); + + _logger.LogTrace("Fetching person image from {Url}", url.Sanitize()); + // Download the file using Flurl + var imageStream = await url + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + using var image = Image.NewFromStream(imageStream); + try + { + image.WriteToFile(targetFile); + } + catch (Exception ex) + { + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.WEBP: + image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.AVIF: + image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + } + + return filename; + } + + private async Task GetCoverPersonImagePath(Person person) + { + var tempFile = Path.Join(_directoryService.LongTermCacheDirectory, "people.yml"); // Check if the file already exists and skip download in Development environment if (File.Exists(tempFile)) @@ -286,7 +347,7 @@ public class CoverDbService : ICoverDbService if (!File.Exists(tempFile)) { var masterPeopleFile = await $"{NewHost}people/people.yml" - .DownloadFileAsync(_directoryService.TempDirectory); + .DownloadFileAsync(_directoryService.LongTermCacheDirectory); if (!File.Exists(tempFile) || string.IsNullOrEmpty(masterPeopleFile)) { @@ -307,66 +368,373 @@ public class CoverDbService : ICoverDbService return $"{NewHost}{coverAuthor.ImagePath}"; } - private static async Task FallbackToKavitaReaderFavicon(string baseUrl) + private async Task FallbackToKavitaReaderFavicon(string baseUrl) { + const string urlsFileName = "publishers.txt"; var correctSizeLink = string.Empty; - // TODO: Pull this down and store it in temp/ to save on requests - var allOverrides = await $"{NewHost}favicons/urls.txt" - .GetStringAsync(); + var allOverrides = await GetCachedData(urlsFileName) ?? + await $"{NewHost}favicons/{urlsFileName}".GetStringAsync(); - if (!string.IsNullOrEmpty(allOverrides)) + // Cache immediately + await CacheDataAsync(urlsFileName, allOverrides); + + + if (string.IsNullOrEmpty(allOverrides)) return correctSizeLink; + + var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); + var externalFile = allOverrides + .Split("\n") + .FirstOrDefault(url => + cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || + cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) + )); + + if (string.IsNullOrEmpty(externalFile)) { - var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); - var externalFile = allOverrides - .Split("\n") - .FirstOrDefault(url => - cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || - cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) - )); - - if (string.IsNullOrEmpty(externalFile)) - { - throw new KavitaException($"Could not grab favicon from {baseUrl.Sanitize()}"); - } - - correctSizeLink = $"{NewHost}favicons/" + externalFile; + throw new KavitaException($"Could not grab favicon from {baseUrl.Sanitize()}"); } - return correctSizeLink; + return $"{NewHost}favicons/{externalFile}"; } - private static async Task FallbackToKavitaReaderPublisher(string publisherName) + private async Task FallbackToKavitaReaderPublisher(string publisherName) { - var externalLink = string.Empty; - // TODO: Pull this down and store it in temp/ to save on requests - var allOverrides = await $"{NewHost}publishers/publishers.txt".GetStringAsync(); + const string publisherFileName = "publishers.txt"; + var allOverrides = await GetCachedData(publisherFileName) ?? + await $"{NewHost}publishers/{publisherFileName}".GetStringAsync(); - if (!string.IsNullOrEmpty(allOverrides)) - { - var externalFile = allOverrides - .Split("\n") - .Select(publisherLine => - { - var tokens = publisherLine.Split("|"); - if (tokens.Length != 2) return null; - var aliases = tokens[0]; - // Multiple publisher aliases are separated by # - if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim()))) - { - return tokens[1]; - } - return null; - }) - .FirstOrDefault(url => !string.IsNullOrEmpty(url)); + // Cache immediately + await CacheDataAsync(publisherFileName, allOverrides); - if (string.IsNullOrEmpty(externalFile)) + if (string.IsNullOrEmpty(allOverrides)) return string.Empty; + + var externalFile = allOverrides + .Split("\n") + .Select(publisherLine => { - throw new KavitaException($"Could not grab publisher image for {publisherName}"); - } + var tokens = publisherLine.Split("|"); + if (tokens.Length != 2) return null; + var aliases = tokens[0]; + // Multiple publisher aliases are separated by # + if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim()))) + { + return tokens[1]; + } + return null; + }) + .FirstOrDefault(url => !string.IsNullOrEmpty(url)); - externalLink = $"{NewHost}publishers/" + externalFile; + if (string.IsNullOrEmpty(externalFile)) + { + throw new KavitaException($"Could not grab publisher image for {publisherName}"); } - return externalLink; + return $"{NewHost}publishers/{externalFile}"; + } + + private async Task CacheDataAsync(string fileName, string? content) + { + if (content == null) return; + + try + { + var filePath = _directoryService.FileSystem.Path.Join(_directoryService.LongTermCacheDirectory, fileName); + await File.WriteAllTextAsync(filePath, content); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache {FileName}", fileName); + } + } + + + private async Task GetCachedData(string cacheFile) + { + // Form the full file path: + var filePath = _directoryService.FileSystem.Path.Join(_directoryService.LongTermCacheDirectory, cacheFile); + if (!File.Exists(filePath)) return null; + + var fileInfo = new FileInfo(filePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + return await File.ReadAllTextAsync(filePath); + } + + return null; + } + + /// + /// + /// + /// + /// + /// + /// Will check against all known null image placeholders to avoid writing it + public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false) + { + if (!string.IsNullOrEmpty(url)) + { + var tempDir = _directoryService.TempDirectory; + var format = ImageService.GetPersonFormat(person.Id); + var finalFileName = format + ".webp"; + var tempFileName = format + "_new"; + var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDir); + + if (!string.IsNullOrEmpty(tempFilePath)) + { + var tempFullPath = Path.Combine(tempDir, tempFilePath); + var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName); + + // Skip setting image if it's similar to a known placeholder + if (checkNoImagePlaceholder) + { + var placeholderPath = Path.Combine(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg"); + var similarity = placeholderPath.CalculateSimilarity(tempFullPath); + if (similarity >= 0.9f) + { + _logger.LogInformation("Skipped setting placeholder image for person {PersonId} due to high similarity ({Similarity})", person.Id, similarity); + _directoryService.DeleteFiles([tempFullPath]); + return; + } + } + + try + { + if (!string.IsNullOrEmpty(person.CoverImage)) + { + var existingPath = Path.Combine(_directoryService.CoverImageDirectory, person.CoverImage); + var betterImage = existingPath.GetBetterImage(tempFullPath)!; + + var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); + if (choseNewImage) + { + _directoryService.DeleteFiles([existingPath]); + _directoryService.CopyFile(tempFullPath, finalFullPath); + person.CoverImage = finalFileName; + } + else + { + _directoryService.DeleteFiles([tempFullPath]); + return; + } + } + else + { + _directoryService.CopyFile(tempFullPath, finalFullPath); + person.CoverImage = finalFileName; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error choosing better image for Person: {PersonId}", person.Id); + _directoryService.CopyFile(tempFullPath, finalFullPath); + person.CoverImage = finalFileName; + } + + _directoryService.DeleteFiles([tempFullPath]); + + person.CoverImageLocked = true; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + } + } + else + { + person.CoverImage = string.Empty; + person.CoverImageLocked = false; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false); + } + } + + /// + /// Sets the series cover by url + /// + /// + /// + /// + /// If images are similar, will choose the higher quality image + public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false) + { + if (!string.IsNullOrEmpty(url)) + { + var tempDir = _directoryService.TempDirectory; + var format = ImageService.GetSeriesFormat(series.Id); + var finalFileName = format + ".webp"; + var tempFileName = format + "_new"; + var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDir); + + if (!string.IsNullOrEmpty(tempFilePath)) + { + var tempFullPath = Path.Combine(tempDir, tempFilePath); + var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName); + + if (chooseBetterImage && !string.IsNullOrEmpty(series.CoverImage)) + { + try + { + var existingPath = Path.Combine(_directoryService.CoverImageDirectory, series.CoverImage); + var betterImage = existingPath.GetBetterImage(tempFullPath)!; + + var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); + if (choseNewImage) + { + // Don't delete the Series cover unless it is an override, otherwise the first chapter will be null + if (existingPath.Contains(ImageService.GetSeriesFormat(series.Id))) + { + _directoryService.DeleteFiles([existingPath]); + } + + _directoryService.CopyFile(tempFullPath, finalFullPath); + series.CoverImage = finalFileName; + } + else + { + _directoryService.DeleteFiles([tempFullPath]); + return; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error choosing better image for Series: {SeriesId}", series.Id); + _directoryService.CopyFile(tempFullPath, finalFullPath); + series.CoverImage = finalFileName; + } + } + else + { + _directoryService.CopyFile(tempFullPath, finalFullPath); + series.CoverImage = finalFileName; + } + + _directoryService.DeleteFiles([tempFullPath]); + series.CoverImageLocked = true; + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + } + } + else + { + series.CoverImage = null; + series.CoverImageLocked = false; + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); + } + } + + // TODO: Refactor this to IHasCoverImage instead of a hard entity type + public async Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false) + { + if (!string.IsNullOrEmpty(url)) + { + var tempDirectory = _directoryService.TempDirectory; + var finalFileName = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + ".webp"; + var tempFileName = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + "_new"; + + var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDirectory); + + if (!string.IsNullOrEmpty(tempFilePath)) + { + var tempFullPath = Path.Combine(tempDirectory, tempFilePath); + var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName); + + if (chooseBetterImage && !string.IsNullOrEmpty(chapter.CoverImage)) + { + try + { + var existingPath = Path.Combine(_directoryService.CoverImageDirectory, chapter.CoverImage); + var betterImage = existingPath.GetBetterImage(tempFullPath)!; + var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); + + if (choseNewImage) + { + // This will fail if Cover gen is done just before this as there is a bug with files getting locked. + _directoryService.DeleteFiles([existingPath]); + _directoryService.CopyFile(tempFullPath, finalFullPath); + _directoryService.DeleteFiles([tempFullPath]); + } + else + { + _directoryService.DeleteFiles([tempFullPath]); + return; + } + + chapter.CoverImage = finalFileName; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue trying to choose a better cover image for Chapter: {FileName} ({ChapterId})", chapter.Range, chapter.Id); + } + } + else + { + // No comparison needed, just copy and rename to final + _directoryService.CopyFile(tempFullPath, finalFullPath); + _directoryService.DeleteFiles([tempFullPath]); + chapter.CoverImage = finalFileName; + } + + chapter.CoverImageLocked = true; + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + } + } + else + { + chapter.CoverImage = null; + chapter.CoverImageLocked = false; + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync( + MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), + false + ); + } + } + + /// + /// + /// + /// + /// Filename without extension + /// + /// Not useable with fromBase64. Allows a different directory to be written to + /// + private async Task CreateThumbnail(string url, string filenameWithoutExtension, bool fromBase64 = true, string? targetDirectory = null) + { + targetDirectory ??= _directoryService.CoverImageDirectory; + + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var encodeFormat = settings.EncodeMediaAs; + var coverImageSize = settings.CoverImageSize; + + if (fromBase64) + { + return _imageService.CreateThumbnailFromBase64(url, + filenameWithoutExtension, encodeFormat, coverImageSize.GetDimensions().Width); + } + + return await DownloadImageFromUrl(filenameWithoutExtension, encodeFormat, url, targetDirectory); } } diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index 89c43f827..bff7001bd 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -179,7 +179,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService var pageCounter = 1; try { - using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(filePath, BookService.LenientBookReaderOptions); var totalPages = book.Content.Html.Local; foreach (var bookPage in totalPages) diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index d2e6437a3..fec0304a8 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -310,7 +310,7 @@ public class LibraryWatcher : ILibraryWatcher if (rootFolder.Count == 0) return string.Empty; // Select the first folder and join with library folder, this should give us the folder to scan. - return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); + return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[^1])); } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index f4405165d..83558eaa0 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -32,6 +32,10 @@ public class ParsedSeries /// Format of the Series /// public required MangaFormat Format { get; init; } + /// + /// Has this Series changed or not aka do we need to process it or not. + /// + public bool HasChanged { get; set; } } public class ScanResult @@ -162,8 +166,10 @@ public class ParseScannedFiles // Don't process any folders where we've already scanned everything below if (processedDirs.Any(d => d.StartsWith(directory + Path.AltDirectorySeparatorChar) || d.Equals(directory))) { + var hasChanged = !HasSeriesFolderNotChangedSinceLastScan(library, seriesPaths, directory, forceCheck); // Skip this directory as we've already processed a parent unless there are loose files at that directory - CheckSurfaceFiles(result, directory, folderPath, fileExtensions, matcher); + // and they have changes + CheckSurfaceFiles(result, directory, folderPath, fileExtensions, matcher, hasChanged); continue; } @@ -178,7 +184,7 @@ public class ParseScannedFiles await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated)); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, directory, forceCheck)) + if (HasSeriesFolderNotChangedSinceLastScan(library, seriesPaths, directory, forceCheck)) { HandleUnchangedFolder(result, folderPath, directory); } @@ -196,12 +202,16 @@ public class ParseScannedFiles /// /// Checks against all folder paths on file if the last scanned is >= the directory's last write time, down to the second /// + /// /// /// This should be normalized /// /// - private bool HasSeriesFolderNotChangedSinceLastScan(IDictionary> seriesPaths, string directory, bool forceCheck) + private bool HasSeriesFolderNotChangedSinceLastScan(Library library, IDictionary> seriesPaths, string directory, bool forceCheck) { + // Reverting code from: https://github.com/Kareadita/Kavita/pull/3619/files#diff-0625df477047ab9d8e97a900201f2f29b2dc0599ba58eb75cfbbd073a9f3c72f + // This is to be able to release hotfix and tackle this in appropriate time + // With the bottom-up approach, this can report a false positive where a nested folder will get scanned even though a parent is the series // This can't really be avoided. This is more likely to happen on Image chapter folder library layouts. if (forceCheck || !seriesPaths.TryGetValue(directory, out var seriesList)) @@ -209,6 +219,18 @@ public class ParseScannedFiles return false; } + // if (forceCheck) + // { + // return false; + // } + + // TryGetSeriesList falls back to parent folders to match to seriesList + // var seriesList = TryGetSeriesList(library, seriesPaths, directory); + // if (seriesList == null) + // { + // return false; + // } + foreach (var series in seriesList) { var lastWriteTime = _directoryService.GetLastWriteTime(series.LowestFolderPath!).Truncate(TimeSpan.TicksPerSecond); @@ -222,6 +244,31 @@ public class ParseScannedFiles return true; } + private IList? TryGetSeriesList(Library library, IDictionary> seriesPaths, string directory) + { + if (seriesPaths.Count == 0) + { + return null; + } + + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + if (library.Folders.Any(fp => fp.Path.Equals(directory))) + { + return null; + } + + if (seriesPaths.TryGetValue(directory, out var seriesList)) + { + return seriesList; + } + + return TryGetSeriesList(library, seriesPaths, _directoryService.GetParentDirectoryName(directory)); + } + /// /// Handles directories that haven't changed since the last scan. /// @@ -255,13 +302,15 @@ public class ParseScannedFiles /// /// Performs a full scan of the directory and adds it to the result. /// - private void CheckSurfaceFiles(List result, string directory, string folderPath, string fileExtensions, GlobMatcher matcher) + private void CheckSurfaceFiles(List result, string directory, string folderPath, string fileExtensions, GlobMatcher matcher, bool hasChanged) { var files = _directoryService.ScanFiles(directory, fileExtensions, matcher, SearchOption.TopDirectoryOnly); if (files.Count == 0) { return; } + // Revert of https://github.com/Kareadita/Kavita/pull/3629/files#diff-0625df477047ab9d8e97a900201f2f29b2dc0599ba58eb75cfbbd073a9f3c72f + // for Hotfix v0.8.5.x result.Add(CreateScanResult(directory, folderPath, true, files)); } @@ -280,7 +329,7 @@ public class ParseScannedFiles await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(normalizedPath, library.Name, ProgressEventType.Updated)); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) + if (HasSeriesFolderNotChangedSinceLastScan(library, seriesPaths, normalizedPath, forceCheck)) { result.Add(CreateScanResult(folderPath, libraryRoot, false, ArraySegment.Empty)); } @@ -674,6 +723,12 @@ public class ParseScannedFiles private static void RemapSeries(IList scanResults, List allInfos, string localizedSeries, string nonLocalizedSeries) { + // If the series names are identical, no remapping is needed (rare but valid) + if (localizedSeries.ToNormalized().Equals(nonLocalizedSeries.ToNormalized())) + { + return; + } + // Find all infos that need to be remapped from the localized series to the non-localized series var normalizedLocalizedSeries = localizedSeries.ToNormalized(); var seriesToBeRemapped = allInfos.Where(i => i.Series.ToNormalized().Equals(normalizedLocalizedSeries)).ToList(); @@ -719,6 +774,11 @@ public class ParseScannedFiles .Select(fp => new ParserInfo { Series = fp.SeriesName, Format = fp.Format }) .ToList(); + // // We are certain TryGetSeriesList will return a valid result here, if the series wasn't present yet. It will have been changed. + // result.ParserInfos = TryGetSeriesList(library, seriesPaths, normalizedFolder)! + // .Select(fp => new ParserInfo { Series = fp.SeriesName, Format = fp.Format }) + // .ToList(); + _logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed", normalizedFolder); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent($"Skipped {normalizedFolder}", library.Name, ProgressEventType.Updated)); @@ -744,7 +804,7 @@ public class ParseScannedFiles { // Process files sequentially result.ParserInfos = files - .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type)) + .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata)) .Where(info => info != null) .ToList()!; } @@ -752,7 +812,7 @@ public class ParseScannedFiles { // Process files in parallel var tasks = files.Select(file => Task.Run(() => - _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type))); + _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata))); var infos = await Task.WhenAll(tasks); result.ParserInfos = infos.Where(info => info != null).ToList()!; @@ -811,7 +871,10 @@ public class ParseScannedFiles var prevIssue = string.Empty; foreach (var chapter in chapters) { - if (float.TryParse(chapter.Chapters, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedChapter)) + // Use MinNumber in case there is a range, as otherwise sort order will cause it to be processed last + var chapterNum = + $"{Parser.Parser.MinNumberFromRange(chapter.Chapters).ToString(CultureInfo.InvariantCulture)}"; + if (float.TryParse(chapterNum, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedChapter)) { // Parsed successfully, use the numeric value counter = parsedChapter; diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 039e3acd6..168ca7f01 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -12,7 +12,7 @@ namespace API.Services.Tasks.Scanner.Parser; /// public class BasicParser(IDirectoryService directoryService, IDefaultParser imageParser) : DefaultParser(directoryService) { - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); // TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this. @@ -20,7 +20,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag if (Parser.IsImage(filePath)) { - return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, comicInfo); + return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, enableMetadata, comicInfo); } var ret = new ParserInfo() @@ -86,7 +86,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag { ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); } - + ret.Title = Parser.CleanSpecialTitle(fileName); } if (string.IsNullOrEmpty(ret.Series)) @@ -101,7 +101,12 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag } // Patch in other information from ComicInfo - UpdateFromComicInfo(ret); + if (enableMetadata) + { + UpdateFromComicInfo(ret); + } + + if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter) { diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs index 499e554ef..14f42c989 100644 --- a/API/Services/Tasks/Scanner/Parser/BookParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -5,7 +5,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class BookParser(IDirectoryService directoryService, IBookService bookService, BasicParser basicParser) : DefaultParser(directoryService) { - public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null) { var info = bookService.ParseInfo(filePath); if (info == null) return null; @@ -35,7 +35,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer } else { - var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo); + var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, enableMetadata, comicInfo); info.Merge(info2); if (hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series, type) .Equals(Parser.LooseLeafVolume)) diff --git a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs index b68596245..b60f28aee 100644 --- a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs @@ -19,7 +19,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser /// /// /// - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { if (type != LibraryType.ComicVine) return null; @@ -81,7 +81,10 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser info.IsSpecial = Parser.IsSpecial(info.Filename, type) || Parser.IsSpecial(info.ComicInfo?.Format, type); // Patch in other information from ComicInfo - UpdateFromComicInfo(info); + if (enableMetadata) + { + UpdateFromComicInfo(info); + } if (string.IsNullOrEmpty(info.Series)) { diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index f59a3b66f..687617fd7 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -8,7 +8,7 @@ namespace API.Services.Tasks.Scanner.Parser; public interface IDefaultParser { - ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); + ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null); void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret); bool IsApplicable(string filePath, LibraryType type); } @@ -26,8 +26,9 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau /// /// Root folder /// Allows different Regex to be used for parsing. + /// Allows overriding data from metadata (ComicInfo/pdf/epub) /// or null if Series was empty - public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); + public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null); /// /// Fills out by trying to parse volume, chapters, and series from folders @@ -130,9 +131,9 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau } // Patch is SeriesSort from ComicInfo - if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort)) + if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort)) { - info.SeriesSort = info.ComicInfo.TitleSort.Trim(); + info.SeriesSort = info.ComicInfo.SeriesSort.Trim(); } } diff --git a/API/Services/Tasks/Scanner/Parser/ImageParser.cs b/API/Services/Tasks/Scanner/Parser/ImageParser.cs index 415533631..12f9f4d50 100644 --- a/API/Services/Tasks/Scanner/Parser/ImageParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ImageParser.cs @@ -7,7 +7,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class ImageParser(IDirectoryService directoryService) : DefaultParser(directoryService) { - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { if (!IsApplicable(filePath, type)) return null; diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 12374d67f..c0b130f91 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Immutable; -using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -25,7 +24,7 @@ public static partial class Parser public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500); - public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)"; // Don't forget to update CoverChooser + public const string ImageFileExtensions = @"(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)"; // Don't forget to update CoverChooser public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt"; public const string EpubFileExtension = @"\.epub"; public const string PdfFileExtension = @"\.pdf"; @@ -44,87 +43,83 @@ public static partial class Parser "One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel", "GN", "FCBD", "Giant Size"); - private static readonly char[] LeadingZeroesTrimChars = new[] { '0' }; + private static readonly char[] LeadingZeroesTrimChars = ['0']; - private static readonly char[] SpacesAndSeparators = { '\0', '\t', '\r', ' ', '-', ','}; + private static readonly char[] SpacesAndSeparators = ['\0', '\t', '\r', ' ', '-', ',']; private const string Number = @"\d+(\.\d)?"; private const string NumberRange = Number + @"(-" + Number + @")?"; /// - /// non greedy matching of a string where parenthesis are balanced + /// non-greedy matching of a string where parenthesis are balanced /// public const string BalancedParen = @"(?:[^()]|(?\()|(?<-open>\)))*?(?(open)(?!))"; /// - /// non greedy matching of a string where square brackets are balanced + /// non-greedy matching of a string where square brackets are balanced /// public const string BalancedBracket = @"(?:[^\[\]]|(?\[)|(?<-open>\]))*?(?(open)(?!))"; /// /// Matches [Complete], release tags like [kmts] but not [ Complete ] or [kmts ] /// private const string TagsInBrackets = $@"\[(?!\s){BalancedBracket}(? - /// Common regex patterns present in both Comics and Mangas - /// - private const string CommonSpecial = @"Specials?|One[- ]?Shot|Extra(?:\sChapter)?(?=\s)|Art Collection|Side Stories|Bonus"; /// /// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data /// /// See here for some examples https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face - public static readonly Regex FontSrcUrlRegex = new Regex(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" - + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", + public static readonly Regex FontSrcUrlRegex = new(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" + + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", MatchOptions, RegexTimeout); /// /// https://developer.mozilla.org/en-US/docs/Web/CSS/@import /// - public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", + public static readonly Regex CssImportUrlRegex = new("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", MatchOptions | RegexOptions.Multiline, RegexTimeout); /// /// Misc css image references, like background-image: url(), border-image, or list-style-image /// /// Original prepend: (background|border|list-style)-image:\s?)? - public static readonly Regex CssImageUrlRegex = new Regex(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", + public static readonly Regex CssImageUrlRegex = new(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", MatchOptions, RegexTimeout); - private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, + private static readonly Regex ImageRegex = new(ImageFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, + private static readonly Regex ArchiveFileRegex = new(ArchiveFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex ComicInfoArchiveRegex = new Regex(@"\.cbz|\.cbr|\.cb7|\.cbt", + private static readonly Regex ComicInfoArchiveRegex = new(@"\.cbz|\.cbr|\.cb7|\.cbt", MatchOptions, RegexTimeout); - private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions, + private static readonly Regex XmlRegex = new(XmlRegexExtensions, MatchOptions, RegexTimeout); - private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, + private static readonly Regex BookFileRegex = new(BookFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex CoverImageRegex = new Regex(@"(? /// Normalize everything within Kavita. Some characters don't fall under Unicode, like full-width characters and need to be /// added on a case-by-case basis. /// - private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!*!+]", + private static readonly Regex NormalizeRegex = new(@"[^\p{L}0-9\+!*!+]", MatchOptions, RegexTimeout); /// /// Supports Batman (2020) or Batman (2) /// - private static readonly Regex SeriesAndYearRegex = new Regex(@"^\D+\s\((?\d+)\)$", + private static readonly Regex SeriesAndYearRegex = new(@"^\D+\s\((?\d+)\)$", MatchOptions, RegexTimeout); /// /// Recognizes the Special token only /// - private static readonly Regex SpecialTokenRegex = new Regex(@"SP\d+", + private static readonly Regex SpecialTokenRegex = new(@"SP\d+", MatchOptions, RegexTimeout); - private static readonly Regex[] MangaVolumeRegex = new[] - { + private static readonly Regex[] MangaVolumeRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -170,9 +165,9 @@ public static partial class Parser new Regex( @"(卷|册)(?\d+)", MatchOptions, RegexTimeout), - // Korean Volume: 제n화|권|회|장 -> Volume n, n화|권|회|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) + // Korean Volume: 제n화|회|장 -> Volume n, n화|권|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) new Regex( - @"제?(?\d+(\.\d)?)(권|회|화|장)", + @"제?(?\d+(\.\d+)?)(권|화|장)", MatchOptions, RegexTimeout), // Korean Season: 시즌n -> Season n, new Regex( @@ -197,11 +192,11 @@ public static partial class Parser // Russian Volume: n Том -> Volume n new Regex( @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] MangaSeriesRegex = new[] - { + private static readonly Regex[] MangaSeriesRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -374,12 +369,12 @@ public static partial class Parser // Japanese Volume: n巻 -> Volume n new Regex( @"(?.+?)第(?\d+(?:(\-)\d+)?)巻", - MatchOptions, RegexTimeout), + MatchOptions, RegexTimeout) - }; + ]; - private static readonly Regex[] ComicSeriesRegex = new[] - { + private static readonly Regex[] ComicSeriesRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -467,11 +462,11 @@ public static partial class Parser // MUST BE LAST: Batman & Daredevil - King of New York new Regex( @"^(?.*)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] ComicVolumeRegex = new[] - { + private static readonly Regex[] ComicVolumeRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -507,11 +502,11 @@ public static partial class Parser // Russian Volume: n Том -> Volume n new Regex( @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] ComicChapterRegex = new[] - { + private static readonly Regex[] ComicChapterRegex = + [ // Thai Volume: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n new Regex( @"(บทที่|ตอนที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -576,11 +571,11 @@ public static partial class Parser // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) new Regex( @"^(?.+?)-(chapter-)?(?\d+)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] MangaChapterRegex = new[] - { + private static readonly Regex[] MangaChapterRegex = + [ // Thai Chapter: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n, เล่ม n -> Volume n, เล่มที่ n -> Volume n new Regex( @"(?((เล่ม|เล่มที่))?(\s|_)?\.?\d+)(\s|_)(บทที่|ตอนที่)\.?(\s|_)?(?\d+)", @@ -645,8 +640,8 @@ public static partial class Parser // Russian Chapter: n Главa -> Chapter n new Regex( @"(?!Том)(?\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; private static readonly Regex MangaEditionRegex = new Regex( // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz @@ -661,25 +656,6 @@ public static partial class Parser MatchOptions, RegexTimeout ); - private static readonly Regex MangaSpecialRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - $@"\b(?:{CommonSpecial}|Omake)\b", - MatchOptions, RegexTimeout - ); - - private static readonly Regex ComicSpecialRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - $@"\b(?:{CommonSpecial}|\d.+?(\W|-|^)Annual|Annual(\W|-|$|\s#)|Book \d.+?|Compendium(\W|-|$|\s.+?)|Omnibus(\W|-|$|\s.+?)|FCBD \d.+?|Absolute(\W|-|$|\s.+?)|Preview(\W|-|$|\s.+?)|Hors[ -]S[ée]rie|TPB|HS|THS)\b", - MatchOptions, RegexTimeout - ); - - private static readonly Regex EuropeanComicRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - @"\b(?:Bd[-\s]Fr)\b", - MatchOptions, RegexTimeout - ); - - // If SP\d+ is in the filename, we force treat it as a special regardless if volume or chapter might have been found. private static readonly Regex SpecialMarkerRegex = new Regex( @"SP\d+", @@ -732,21 +708,7 @@ public static partial class Parser return HasSpecialMarker(filePath); } - private static bool IsMangaSpecial(string? filePath) - { - if (string.IsNullOrEmpty(filePath)) return false; - return HasSpecialMarker(filePath); - } - - private static bool IsComicSpecial(string? filePath) - { - if (string.IsNullOrEmpty(filePath)) return false; - return HasSpecialMarker(filePath); - } - - - - public static string ParseMangaSeries(string filename) + private static string ParseMangaSeries(string filename) { foreach (var regex in MangaSeriesRegex) { @@ -754,6 +716,7 @@ public static partial class Parser var group = matches .Select(match => match.Groups["Series"]) .FirstOrDefault(group => group.Success && group != Match.Empty); + if (group != null) { return CleanTitle(group.Value); @@ -932,22 +895,6 @@ public static partial class Parser return title; } - private static string RemoveMangaSpecialTags(string title) - { - return MangaSpecialRegex.Replace(title, string.Empty); - } - - private static string RemoveEuropeanTags(string title) - { - return EuropeanComicRegex.Replace(title, string.Empty); - } - - private static string RemoveComicSpecialTags(string title) - { - return ComicSpecialRegex.Replace(title, string.Empty); - } - - /// /// Translates _ -> spaces, trims front and back of string, removes release groups @@ -966,20 +913,6 @@ public static partial class Parser title = RemoveEditionTagHolders(title); - // if (replaceSpecials) - // { - // if (isComic) - // { - // title = RemoveComicSpecialTags(title); - // title = RemoveEuropeanTags(title); - // } - // else - // { - // title = RemoveMangaSpecialTags(title); - // } - // } - - title = title.Trim(SpacesAndSeparators); title = EmptySpaceRegex.Replace(title, " "); @@ -1110,11 +1043,6 @@ public static partial class Parser { if (string.IsNullOrEmpty(name)) return name; var cleaned = SpecialTokenRegex.Replace(name.Replace('_', ' '), string.Empty).Trim(); - var lastIndex = cleaned.LastIndexOf('.'); - if (lastIndex > 0) - { - cleaned = cleaned.Substring(0, cleaned.LastIndexOf('.')).Trim(); - } return string.IsNullOrEmpty(cleaned) ? name : cleaned; } @@ -1132,7 +1060,7 @@ public static partial class Parser } /// - /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc and that if a full path, the filename + /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc. and that if a full path, the filename /// doesn't start with ._, which is a metadata file on MACOSX. /// /// @@ -1231,6 +1159,12 @@ public static partial class Parser return !string.IsNullOrEmpty(name) && SeriesAndYearRegex.IsMatch(name); } + /// + /// Parse a Year from a Comic Series: Series Name (YEAR) + /// + /// Harley Quinn (2024) returns 2024 + /// + /// public static string ParseYear(string? name) { if (string.IsNullOrEmpty(name)) return string.Empty; diff --git a/API/Services/Tasks/Scanner/Parser/PdfParser.cs b/API/Services/Tasks/Scanner/Parser/PdfParser.cs index 696a61867..80bfa9a48 100644 --- a/API/Services/Tasks/Scanner/Parser/PdfParser.cs +++ b/API/Services/Tasks/Scanner/Parser/PdfParser.cs @@ -6,7 +6,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class PdfParser(IDirectoryService directoryService) : DefaultParser(directoryService) { - public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null) { var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); var ret = new ParserInfo @@ -68,6 +68,18 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); } + if (enableMetadata) + { + // Patch in other information from ComicInfo + UpdateFromComicInfo(ret); + + if (comicInfo != null && !string.IsNullOrEmpty(comicInfo.Title)) + { + ret.Title = comicInfo.Title.Trim(); + } + } + + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book) { ret.IsSpecial = true; @@ -76,6 +88,19 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc ParseFromFallbackFolders(filePath, rootPath, type, ref ret); } + if (type == LibraryType.Book && comicInfo != null) + { + // For books, fall back to the Title for Series. + if (!string.IsNullOrEmpty(comicInfo.Series)) + { + ret.Series = comicInfo.Series.Trim(); + } + else if (!string.IsNullOrEmpty(comicInfo.Title)) + { + ret.Series = comicInfo.Title.Trim(); + } + } + if (string.IsNullOrEmpty(ret.Series)) { ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic); diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index a36c8268a..307408adb 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -11,6 +11,7 @@ using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers; using API.Helpers.Builders; @@ -125,13 +126,17 @@ public class ProcessSeries : IProcessSeries series.Format = firstParsedInfo.Format; } + var removePrefix = library.RemovePrefixForSortName; + var sortName = removePrefix ? BookSortTitlePrefixHelper.GetSortTitle(series.Name) : series.Name; + if (string.IsNullOrEmpty(series.SortName)) { - series.SortName = series.Name; + series.SortName = sortName; } + if (!series.SortNameLocked) { - series.SortName = series.Name; + series.SortName = sortName; if (!string.IsNullOrEmpty(firstParsedInfo.SeriesSort)) { series.SortName = firstParsedInfo.SeriesSort; @@ -193,8 +198,8 @@ public class ProcessSeries : IProcessSeries if (seriesAdded) { // See if any recommendations can link up to the series and pre-fetch external metadata for the series - BackgroundJob.Enqueue(() => - _externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type)); + // BackgroundJob.Enqueue(() => + // _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type)); await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false); @@ -213,6 +218,10 @@ public class ProcessSeries : IProcessSeries return; } + if (seriesAdded) + { + await _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type); + } await _metadataService.GenerateCoversForSeries(series.LibraryId, series.Id, false, false); await _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate); } @@ -285,7 +294,7 @@ public class ProcessSeries : IProcessSeries var firstChapter = SeriesService.GetFirstChapterForMetadata(series); var firstFile = firstChapter?.Files.FirstOrDefault(); - if (firstFile == null || Parser.Parser.IsPdf(firstFile.FilePath)) return; + if (firstFile == null) return; var chapters = series.Volumes .SelectMany(volume => volume.Chapters) @@ -298,7 +307,19 @@ public class ProcessSeries : IProcessSeries } // Set the AgeRating as highest in all the comicInfos - if (!series.Metadata.AgeRatingLocked) series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); + if (!series.Metadata.AgeRatingLocked) + { + series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); + + // Get the MetadataSettings and apply Age Rating Mappings here + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); + var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); + if (updatedRating > series.Metadata.AgeRating) + { + series.Metadata.AgeRating = updatedRating; + } + } DeterminePublicationStatus(series, chapters); @@ -318,86 +339,124 @@ public class ProcessSeries : IProcessSeries await UpdateCollectionTags(series, firstChapter); } - #region PeopleAndTagsAndGenres - if (!series.Metadata.WriterLocked) - { - var personSw = Stopwatch.StartNew(); - var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); - _logger.LogDebug("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count); - } + if (!series.Metadata.WriterLocked) + { + var personSw = Stopwatch.StartNew(); + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); + } + _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count); + } if (!series.Metadata.ColoristLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Colorist)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Colorist); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Colorist)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Colorist); + } } if (!series.Metadata.PublisherLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Publisher)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Publisher); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Publisher)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Publisher); + } } if (!series.Metadata.CoverArtistLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.CoverArtist)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.CoverArtist); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.CoverArtist)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.CoverArtist); + } } if (!series.Metadata.CharacterLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Character)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Character); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Character)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Character); + } } if (!series.Metadata.EditorLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Editor)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Editor); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Editor)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Editor); + } } if (!series.Metadata.InkerLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Inker)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Inker); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Inker)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Inker); + } } if (!series.Metadata.ImprintLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Imprint)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Imprint); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Imprint)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Imprint); + } } if (!series.Metadata.TeamLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Team)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Team); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Team)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Team); + } } - if (!series.Metadata.LocationLocked) + if (!series.Metadata.LocationLocked && !series.Metadata.AllKavitaPlus(PersonRole.Location)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Location)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Location); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Location)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Location); + } } - if (!series.Metadata.LettererLocked) + if (!series.Metadata.LettererLocked && !series.Metadata.AllKavitaPlus(PersonRole.Letterer)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Letterer)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Letterer); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Location)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Letterer); + } } - if (!series.Metadata.PencillerLocked) + if (!series.Metadata.PencillerLocked && !series.Metadata.AllKavitaPlus(PersonRole.Penciller)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Penciller)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Penciller); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Penciller)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Penciller); + } } - if (!series.Metadata.TranslatorLocked) + if (!series.Metadata.TranslatorLocked && !series.Metadata.AllKavitaPlus(PersonRole.Translator)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Translator)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Translator); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Translator)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Translator); + } } @@ -414,6 +473,35 @@ public class ProcessSeries : IProcessSeries } #endregion + + } + + /// + /// Ensure that we don't overwrite Person metadata when all metadata is coming from Kavita+ metadata match functionality + /// + /// + /// + /// + /// + private static bool ShouldUpdatePeopleForRole(Series series, List chapterPeople, PersonRole role) + { + if (chapterPeople.Count == 0) return false; + + // If metadata already has this role, but all entries are from KavitaPlus, we should retain them + if (series.Metadata.AnyOfRole(role)) + { + var existingPeople = series.Metadata.People.Where(p => p.Role == role); + + // If all existing people are KavitaPlus but new chapter people exist, we should still update + if (existingPeople.All(p => p.KavitaPlusConnection)) + { + return false; // Ensure we don't remove KavitaPlus people + } + + return true; // Default case: metadata exists, and it's okay to update + } + + return true; } private async Task UpdateCollectionTags(Series series, Chapter firstChapter) @@ -457,7 +545,7 @@ public class ProcessSeries : IProcessSeries await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collectionTag); } - _logger.LogDebug("[TIME] Kavita took {Time} ms to process collections on Series: {Name}", sw.ElapsedMilliseconds, series.Name); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process collections on Series: {Name}", sw.ElapsedMilliseconds, series.Name); } @@ -541,8 +629,8 @@ public class ProcessSeries : IProcessSeries .Where(v => v.MaxNumber.IsNot(Parser.Parser.SpecialVolumeNumber)) .ToList(); - var maxVolume = (int) (nonSpecialVolumes.Any() ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); - var maxChapter = (int) chapters.Max(c => c.MaxNumber); + var maxVolume = (int)(nonSpecialVolumes.Any() ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); + var maxChapter = (int)chapters.Max(c => c.MaxNumber); // Single books usually don't have a number in their Range (filename) if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1) @@ -703,7 +791,7 @@ public class ProcessSeries : IProcessSeries chapter.SortOrder = info.IssueOrder; } - if (float.TryParse(chapter.Title, out _)) + if (float.TryParse(chapter.Title, CultureInfo.InvariantCulture, out _)) { // If we have float based chapters, first scan can have the chapter formatted as Chapter 0.2 - .2 as the title is wrong. chapter.Title = chapter.GetNumberTitle(); @@ -786,13 +874,18 @@ public class ProcessSeries : IProcessSeries var fileInfo = _directoryService.FileSystem.FileInfo.New(info.FullFilePath); if (existingFile != null) { + // TODO: I wonder if we can simplify this force check. existingFile.Format = info.Format; + if (!forceUpdate && !_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return; + existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format); existingFile.Extension = fileInfo.Extension.ToLowerInvariant(); existingFile.FileName = Parser.Parser.RemoveExtensionIfSupported(existingFile.FilePath); existingFile.FilePath = Parser.Parser.NormalizePath(existingFile.FilePath); existingFile.Bytes = fileInfo.Length; + existingFile.KoreaderHash = KoreaderHelper.HashContents(existingFile.FilePath); + // We skip updating DB here with last modified time so that metadata refresh can do it } else @@ -801,6 +894,7 @@ public class ProcessSeries : IProcessSeries var file = new MangaFileBuilder(info.FullFilePath, info.Format, _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format)) .WithExtension(fileInfo.Extension) .WithBytes(fileInfo.Length) + .WithHash() .Build(); chapter.Files.Add(file); } @@ -894,82 +988,82 @@ public class ProcessSeries : IProcessSeries chapter.ReleaseDate = new DateTime(comicInfo.Year, month, day); } - if (!chapter.ColoristLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Colorist)) { var people = TagHelper.GetTagValues(comicInfo.Colorist); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Colorist); } - if (!chapter.CharacterLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Character)) { var people = TagHelper.GetTagValues(comicInfo.Characters); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Character); } - if (!chapter.TranslatorLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Translator)) { var people = TagHelper.GetTagValues(comicInfo.Translator); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Translator); } - if (!chapter.WriterLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Writer)) { var personSw = Stopwatch.StartNew(); var people = TagHelper.GetTagValues(comicInfo.Writer); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Writer); - _logger.LogDebug("[TIME] Kavita took {Time} ms to process writer on Chapter: {File} for {Count} people", personSw.ElapsedMilliseconds, chapter.Files.First().FileName, people.Count); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Chapter: {File} for {Count} people", personSw.ElapsedMilliseconds, chapter.Files.First().FileName, people.Count); } - if (!chapter.EditorLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Editor)) { var people = TagHelper.GetTagValues(comicInfo.Editor); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Editor); } - if (!chapter.InkerLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Inker)) { var people = TagHelper.GetTagValues(comicInfo.Inker); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Inker); } - if (!chapter.LettererLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Letterer)) { var people = TagHelper.GetTagValues(comicInfo.Letterer); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Letterer); } - if (!chapter.PencillerLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Penciller)) { var people = TagHelper.GetTagValues(comicInfo.Penciller); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Penciller); } - if (!chapter.CoverArtistLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.CoverArtist)) { var people = TagHelper.GetTagValues(comicInfo.CoverArtist); await UpdateChapterPeopleAsync(chapter, people, PersonRole.CoverArtist); } - if (!chapter.PublisherLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Publisher)) { var people = TagHelper.GetTagValues(comicInfo.Publisher); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Publisher); } - if (!chapter.ImprintLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Imprint)) { var people = TagHelper.GetTagValues(comicInfo.Imprint); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Imprint); } - if (!chapter.TeamLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Team)) { var people = TagHelper.GetTagValues(comicInfo.Teams); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Team); } - if (!chapter.LocationLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Location)) { var people = TagHelper.GetTagValues(comicInfo.Locations); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Location); @@ -987,7 +1081,7 @@ public class ProcessSeries : IProcessSeries await UpdateChapterTags(chapter, tags); } - _logger.LogDebug("[TIME] Kavita took {Time} ms to create/update Chapter: {File}", sw.ElapsedMilliseconds, chapter.Files.First().FileName); + _logger.LogTrace("[TIME] Kavita took {Time} ms to create/update Chapter: {File}", sw.ElapsedMilliseconds, chapter.Files.First().FileName); } private async Task UpdateChapterGenres(Chapter chapter, IEnumerable genreNames) diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 3795ed8db..cb5f4302f 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -161,7 +161,7 @@ public class ScannerService : IScannerService { if (TaskScheduler.HasScanTaskRunningForSeries(series.Id)) { - _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); + _logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); return; } @@ -186,7 +186,7 @@ public class ScannerService : IScannerService { if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id)) { - _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); + _logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); return; } BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1)); @@ -317,7 +317,7 @@ public class ScannerService : IScannerService // Process Series var seriesProcessStopWatch = Stopwatch.StartNew(); await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks); - _logger.LogDebug("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, parsedSeries[pSeries][0].Series); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, parsedSeries[pSeries][0].Series); seriesLeftToProcess--; } @@ -335,11 +335,21 @@ public class ScannerService : IScannerService private static Dictionary> TrackFoundSeriesAndFiles(IList seenSeries) { + // Why does this only grab things that have changed? var parsedSeries = new Dictionary>(); - foreach (var series in seenSeries.Where(s => s.ParsedInfos.Count > 0 && s.HasChanged)) + foreach (var series in seenSeries.Where(s => s.ParsedInfos.Count > 0)) // && s.HasChanged { var parsedFiles = series.ParsedInfos; - parsedSeries.Add(series.ParsedSeries, parsedFiles); + series.ParsedSeries.HasChanged = series.HasChanged; + + if (series.HasChanged) + { + parsedSeries.Add(series.ParsedSeries, parsedFiles); + } + else + { + parsedSeries.Add(series.ParsedSeries, []); + } } return parsedSeries; @@ -450,12 +460,12 @@ public class ScannerService : IScannerService // That way logging and UI informing is all in one place with full context _logger.LogError("[ScannerService] Some of the root folders for the library are empty. " + "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan has be aborted. " + + "Scan has been aborted. " + "Check that your mount is connected or change the library's root folder and rescan"); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.", "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan has be aborted. " + + "Scan has been aborted. " + "Check that your mount is connected or change the library's root folder and rescan")); return false; @@ -511,11 +521,16 @@ public class ScannerService : IScannerService // Validations are done, now we can start actual scan _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); + if (!library.EnableMetadata) + { + _logger.LogInformation("[ScannerService] Warning! {LibraryName} has metadata turned off", library.Name); + } + // This doesn't work for something like M:/Manga/ and a series has library folder as root var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths)); if (!shouldUseLibraryScan) { - _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name); + _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders as a library root, using series scan", library.Name); } @@ -601,6 +616,12 @@ public class ScannerService : IScannerService foreach (var series in parsedSeries) { + if (!series.Key.HasChanged) + { + _logger.LogDebug("{Series} hasn't changed", series.Key.Name); + continue; + } + // Filter out ParserInfos where FullFilePath is empty (i.e., folder not modified) var validInfos = series.Value.Where(info => !string.IsNullOrEmpty(info.Filename)).ToList(); @@ -644,7 +665,7 @@ public class ScannerService : IScannerService totalFiles += pSeries.Value.Count; var seriesProcessStopWatch = Stopwatch.StartNew(); await _processSeries.ProcessSeriesAsync(pSeries.Value, library, seriesLeftToProcess, forceUpdate); - _logger.LogDebug("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, pSeries.Value[0].Series); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, pSeries.Value[0].Series); seriesLeftToProcess--; } diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index aaa06a846..3dca14ab9 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -2,12 +2,14 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json.Serialization; using System.Threading.Tasks; using API.Data; using API.DTOs.Theme; using API.Entities; using API.Entities.Enums.Theme; using API.Extensions; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Flurl.Http; using HtmlAgilityPack; @@ -32,6 +34,7 @@ internal class GitHubContent [JsonProperty("type")] public string Type { get; set; } + [JsonPropertyName("download_url")] [JsonProperty("download_url")] public string DownloadUrl { get; set; } @@ -151,6 +154,7 @@ public class ThemeService : IThemeService // Fetch contents of the theme directory var themeContents = await GetDirectoryContent(themeDir.Path); + // Find css and preview files var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css")); var previewUrls = GetPreviewUrls(themeContents); @@ -187,19 +191,22 @@ public class ThemeService : IThemeService return themeDtos; } - private static IList GetPreviewUrls(IEnumerable themeContents) + private static List GetPreviewUrls(IEnumerable themeContents) { - return themeContents.Where(c => c.Name.ToLower().EndsWith(".jpg") || c.Name.ToLower().EndsWith(".png") ) + return themeContents + .Where(c => Parser.IsImage(c.Name) ) .Select(p => p.DownloadUrl) .ToList(); } private static async Task> GetDirectoryContent(string path) { - return await $"{GithubBaseUrl}/repos/Kareadita/Themes/contents/{path}" + var json = await $"{GithubBaseUrl}/repos/Kareadita/Themes/contents/{path}" .WithHeader("Accept", "application/vnd.github+json") .WithHeader("User-Agent", "Kavita") - .GetJsonAsync>(); + .GetStringAsync(); + + return string.IsNullOrEmpty(json) ? [] : JsonConvert.DeserializeObject>(json); } /// @@ -295,7 +302,8 @@ public class ThemeService : IThemeService var existingThemes = _directoryService.ScanFiles(_directoryService.SiteThemeDirectory, string.Empty); if (existingThemes.Any(f => Path.GetFileName(f) == dto.CssFile)) { - throw new KavitaException("Cannot download file, file already on disk"); + // This can happen if you delete then immediately download (to refresh). We should just delete the old file and download. Users can always rollback their version with github directly + _directoryService.DeleteFiles(existingThemes.Where(f => Path.GetFileName(f) == dto.CssFile)); } var finalLocation = await DownloadSiteTheme(dto); diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 4e6fcfb60..5d5df6647 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -13,14 +13,17 @@ using API.DTOs.Stats; using API.DTOs.Stats.V3; using API.Entities; using API.Entities.Enums; +using API.Extensions; using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using Flurl.Http; +using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Services.Tasks; @@ -45,12 +48,12 @@ public class StatsService : IStatsService private readonly UserManager _userManager; private readonly IEmailService _emailService; private readonly ICacheService _cacheService; - private const string ApiUrl = "https://stats.kavitareader.com"; + private readonly string _apiUrl = ""; private const string ApiKey = "MsnvA2DfQqxSK5jh"; // It's not important this is public, just a way to keep bots from hitting the API willy-nilly public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, ILicenseService licenseService, UserManager userManager, IEmailService emailService, - ICacheService cacheService) + ICacheService cacheService, IHostEnvironment environment) { _logger = logger; _unitOfWork = unitOfWork; @@ -60,8 +63,9 @@ public class StatsService : IStatsService _emailService = emailService; _cacheService = cacheService; - FlurlHttp.ConfigureClient(ApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.StatsApiUrl); + + _apiUrl = environment.IsDevelopment() ? "http://localhost:5001" : Configuration.StatsApiUrl; } /// @@ -99,13 +103,8 @@ public class StatsService : IStatsService try { - var response = await (ApiUrl + "/api/v3/stats") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", ApiKey) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(30)) + var response = await (_apiUrl + "/api/v3/stats") + .WithBasicHeaders(ApiKey) .PostJsonAsync(data); if (response.StatusCode != StatusCodes.Status200OK) @@ -152,12 +151,8 @@ public class StatsService : IStatsService try { - var response = await (ApiUrl + "/api/v2/stats/opt-out?installId=" + installId) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", ApiKey) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") + var response = await (_apiUrl + "/api/v2/stats/opt-out?installId=" + installId) + .WithBasicHeaders(ApiKey) .WithTimeout(TimeSpan.FromSeconds(30)) .PostAsync(); @@ -181,13 +176,8 @@ public class StatsService : IStatsService try { var sw = Stopwatch.StartNew(); - var response = await (ApiUrl + "/api/health/") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", ApiKey) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(30)) + var response = await (Configuration.StatsApiUrl + "/api/health/") + .WithBasicHeaders(ApiKey) .GetAsync(); if (response.StatusCode == StatusCodes.Status200OK) @@ -206,7 +196,7 @@ public class StatsService : IStatsService private async Task MaxSeriesInAnyLibrary() { - // If first time flow, just return 0 + // If first time flow, return 0 if (!await _context.Series.AnyAsync()) return 0; return await _context.Series .Select(s => _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series!).Count()) @@ -245,6 +235,7 @@ public class StatsService : IStatsService private async Task GetStatV3Payload() { var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var mediaSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); var dto = new ServerInfoV3Dto() { InstallId = serverSettings.InstallId, @@ -257,6 +248,7 @@ public class StatsService : IStatsService DotnetVersion = Environment.Version.ToString(), OpdsEnabled = serverSettings.EnableOpds, EncodeMediaAs = serverSettings.EncodeMediaAs, + MatchedMetadataEnabled = mediaSettings.Enabled }; dto.OsLocale = CultureInfo.CurrentCulture.EnglishName; diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index f1a6eb383..4ccf79abb 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -1,8 +1,13 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.DTOs.Update; +using API.Extensions; using API.SignalR; using Flurl.Http; using Kavita.Common.EnvironmentInfo; @@ -30,7 +35,7 @@ internal class GithubReleaseMetadata /// public required string Body { get; init; } /// - /// Url of the release on Github + /// Url of the release on GitHub /// // ReSharper disable once InconsistentNaming public required string Html_Url { get; init; } @@ -45,11 +50,13 @@ public interface IVersionUpdaterService { Task CheckForUpdate(); Task PushUpdate(UpdateNotificationDto update); - Task> GetAllReleases(); - Task GetNumberOfReleasesBehind(); + Task> GetAllReleases(int count = 0); + Task GetNumberOfReleasesBehind(bool stableOnly = false); + void BustGithubCache(); } -public class VersionUpdaterService : IVersionUpdaterService + +public partial class VersionUpdaterService : IVersionUpdaterService { private readonly ILogger _logger; private readonly IEventHub _eventHub; @@ -57,37 +64,253 @@ public class VersionUpdaterService : IVersionUpdaterService #pragma warning disable S1075 private const string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest"; private const string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases"; + private const string GithubPullsUrl = "https://api.github.com/repos/Kareadita/Kavita/pulls/"; + private const string GithubBranchCommitsUrl = "https://api.github.com/repos/Kareadita/Kavita/commits?sha=develop"; #pragma warning restore S1075 - public VersionUpdaterService(ILogger logger, IEventHub eventHub) + [GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)] + private static partial Regex BlogPartRegex(); + private readonly string _cacheFilePath; + /// + /// The latest release cache + /// + private readonly string _cacheLatestReleaseFilePath; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public VersionUpdaterService(ILogger logger, IEventHub eventHub, IDirectoryService directoryService) { _logger = logger; _eventHub = eventHub; + _cacheFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_releases_cache.json"); + _cacheLatestReleaseFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_latest_release_cache.json"); - FlurlHttp.ConfigureClient(GithubLatestReleasesUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - FlurlHttp.ConfigureClient(GithubAllReleasesUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(GithubLatestReleasesUrl); + FlurlConfiguration.ConfigureClientForUrl(GithubAllReleasesUrl); } /// - /// Fetches the latest release from Github + /// Fetches the latest (stable) release from GitHub. Does not do any extra nightly release parsing. /// /// Latest update public async Task CheckForUpdate() { + // Attempt to fetch from cache + var cachedRelease = await TryGetCachedLatestRelease(); + if (cachedRelease != null) + { + return cachedRelease; + } + var update = await GetGithubRelease(); - return CreateDto(update); + var dto = CreateDto(update); + + if (dto != null) + { + await CacheLatestReleaseAsync(dto); + } + + return dto; } - public async Task> GetAllReleases() + /// + /// Will add any extra (nightly) updates from the latest stable. Does not back-fill anything prior to the latest stable. + /// + /// + private async Task EnrichWithNightlyInfo(List dtos) { + var dto = dtos[0]; // Latest version + try + { + var currentVersion = new Version(dto.CurrentVersion); + var nightlyReleases = await GetNightlyReleases(currentVersion, Version.Parse(dto.UpdateVersion)); + + if (nightlyReleases.Count == 0) return; + + // Create new DTOs for each nightly release and insert them at the beginning of the list + var nightlyDtos = new List(); + foreach (var nightly in nightlyReleases) + { + var prInfo = await FetchPullRequestInfo(nightly.PrNumber); + if (prInfo == null) continue; + + var sections = ParseReleaseBody(prInfo.Body); + var blogPart = ExtractBlogPart(prInfo.Body); + + var nightlyDto = new UpdateNotificationDto + { + // TODO: I should pass Title to the FE so that Nightly Release can be localized + UpdateTitle = $"Nightly Release {nightly.Version} - {prInfo.Title}", + UpdateVersion = nightly.Version, + CurrentVersion = dto.CurrentVersion, + UpdateUrl = prInfo.Html_Url, + PublishDate = prInfo.Merged_At, + IsDocker = true, // Nightlies are always Docker Only + IsReleaseEqual = IsVersionEqualToBuildVersion(Version.Parse(nightly.Version)), + IsReleaseNewer = true, // Since we already filtered these in GetNightlyReleases + IsPrerelease = true, // All Nightlies are considered prerelease + Added = sections.TryGetValue("Added", out var added) ? added : [], + Changed = sections.TryGetValue("Changed", out var changed) ? changed : [], + Fixed = sections.TryGetValue("Fixed", out var bugfixes) ? bugfixes : [], + Removed = sections.TryGetValue("Removed", out var removed) ? removed : [], + Theme = sections.TryGetValue("Theme", out var theme) ? theme : [], + Developer = sections.TryGetValue("Developer", out var developer) ? developer : [], + KnownIssues = sections.TryGetValue("KnownIssues", out var knownIssues) ? knownIssues : [], + Api = sections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = sections.TryGetValue("Feature Requests", out var frs) ? frs : [], + BlogPart = _markdown.Transform(blogPart.Trim()), + UpdateBody = _markdown.Transform(prInfo.Body.Trim()) + }; + + nightlyDtos.Add(nightlyDto); + } + + // Insert nightly releases at the beginning of the list + var sortedNightlyDtos = nightlyDtos.OrderByDescending(x => x.PublishDate).ToList(); + dtos.InsertRange(0, sortedNightlyDtos); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to enrich nightly release information"); + } + } + + + private async Task FetchPullRequestInfo(int prNumber) + { + try + { + return await $"{GithubPullsUrl}{prNumber}" + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch PR information for #{PrNumber}", prNumber); + return null; + } + } + + private async Task> GetNightlyReleases(Version currentVersion, Version latestStableVersion) + { + try + { + var nightlyReleases = new List(); + + var commits = await GithubBranchCommitsUrl + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync>(); + + var commitList = commits.ToList(); + bool foundLastStable = false; + + for (var i = 0; i < commitList.Count - 1; i++) + { + var commit = commitList[i]; + var message = commit.Commit.Message.Split('\n')[0]; // Take first line only + + // Skip [skip ci] commits + if (message.Contains("[skip ci]")) continue; + + // Check if this is a stable release + if (message.StartsWith('v')) + { + var stableMatch = Regex.Match(message, @"v(\d+\.\d+\.\d+\.\d+)"); + if (stableMatch.Success) + { + var stableVersion = new Version(stableMatch.Groups[1].Value); + // If we find a stable version lower than current, we've gone too far back + if (stableVersion <= currentVersion) + { + foundLastStable = true; + break; + } + } + continue; + } + + // Look for version bumps that follow PRs + if (!foundLastStable && message == "Bump versions by dotnet-bump-version.") + { + // Get the PR commit that triggered this version bump + if (i + 1 < commitList.Count) + { + var prCommit = commitList[i + 1]; + var prMessage = prCommit.Commit.Message.Split('\n')[0]; + + // Extract PR number using improved regex + var prMatch = Regex.Match(prMessage, @"(?:^|\s)\(#(\d+)\)|\s#(\d+)"); + if (!prMatch.Success) continue; + + var prNumber = int.Parse(prMatch.Groups[1].Value != "" ? + prMatch.Groups[1].Value : prMatch.Groups[2].Value); + + // Get the version from AssemblyInfo.cs in this commit + var version = await GetVersionFromCommit(commit.Sha); + if (version == null) continue; + + // Parse version and compare with current version + if (Version.TryParse(version, out var parsedVersion) && + parsedVersion > latestStableVersion) + { + nightlyReleases.Add(new NightlyInfo + { + Version = version, + PrNumber = prNumber, + Date = DateTime.Parse(commit.Commit.Author.Date, CultureInfo.InvariantCulture) + }); + } + } + } + } + + return nightlyReleases.OrderByDescending(x => x.Date).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get nightly releases"); + return []; + } + } + + public async Task> GetAllReleases(int count = 0) + { + // Attempt to fetch from cache + var cachedReleases = await TryGetCachedReleases(); + // If there is a cached release and the current version is within it, use it, otherwise regenerate + if (cachedReleases != null && cachedReleases.Any(r => IsVersionEqual(r.UpdateVersion, BuildInfo.Version.ToString()))) + { + if (count > 0) + { + // NOTE: We may want to allow the admin to clear Github cache + return cachedReleases.Take(count).ToList(); + } + + return cachedReleases; + } + var updates = await GetGithubReleases(); - var updateDtos = updates.Select(CreateDto) + var query = updates.Select(CreateDto) .Where(d => d != null) .OrderByDescending(d => d!.PublishDate) - .Select(d => d!) - .ToList(); + .Select(d => d!); + + var updateDtos = query.ToList(); + + // Sometimes a release can be 0.8.5.0 on disk, but 0.8.5 from Github + var versionParts = updateDtos[0].UpdateVersion.Split('.'); + if (versionParts.Length < 4) + { + updateDtos[0].UpdateVersion += ".0"; // Append missing parts + } + + // If we're on a nightly build, enrich the information + if (updateDtos.Count != 0) // && BuildInfo.Version > new Version(updateDtos[0].UpdateVersion) + { + await EnrichWithNightlyInfo(updateDtos); + } // Find the latest dto var latestRelease = updateDtos[0]!; @@ -103,30 +326,140 @@ public class VersionUpdaterService : IVersionUpdaterService latestRelease.IsOnNightlyInRelease = isNightly; + // Cache the fetched data + if (updateDtos.Count > 0) + { + await CacheReleasesAsync(updateDtos); + } + + if (count > 0) + { + return updateDtos.Take(count).ToList(); + } + return updateDtos; } + /// + /// Compares 2 versions and ensures that the minor is always there + /// + /// + /// + /// + private static bool IsVersionEqual(string v1, string v2) + { + var versionParts = v1.Split('.'); + if (versionParts.Length < 4) + { + v1 += ".0"; // Append missing parts + } + + versionParts = v2.Split('.'); + if (versionParts.Length < 4) + { + v2 += ".0"; // Append missing parts + } + + return string.Equals(v2, v2, StringComparison.OrdinalIgnoreCase); + } + + private async Task?> TryGetCachedReleases() + { + if (!File.Exists(_cacheFilePath)) return null; + + var fileInfo = new FileInfo(_cacheFilePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + var cachedData = await File.ReadAllTextAsync(_cacheFilePath); + return JsonSerializer.Deserialize>(cachedData); + } + + return null; + } + + private async Task TryGetCachedLatestRelease() + { + if (!File.Exists(_cacheLatestReleaseFilePath)) return null; + + var fileInfo = new FileInfo(_cacheLatestReleaseFilePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath); + return JsonSerializer.Deserialize(cachedData); + } + + return null; + } + + private async Task CacheReleasesAsync(IList updates) + { + try + { + var json = JsonSerializer.Serialize(updates, JsonOptions); + await File.WriteAllTextAsync(_cacheFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache releases"); + } + } + + private async Task CacheLatestReleaseAsync(UpdateNotificationDto update) + { + try + { + var json = JsonSerializer.Serialize(update, JsonOptions); + await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache latest release"); + } + } + + + private static bool IsVersionEqualToBuildVersion(Version updateVersion) { - return updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 && - CompareWithoutRevision(BuildInfo.Version, updateVersion); + return updateVersion == BuildInfo.Version || (updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 && + BuildInfo.Version.CompareWithoutRevision(updateVersion)); } - private static bool CompareWithoutRevision(Version v1, Version v2) - { - if (v1.Major != v2.Major) - return v1.Major == v2.Major; - if (v1.Minor != v2.Minor) - return v1.Minor == v2.Minor; - if (v1.Build != v2.Build) - return v1.Build == v2.Build; - return true; - } - public async Task GetNumberOfReleasesBehind() + /// + /// Returns the number of releases ahead of this install version. If this install version is on a nightly, + /// then include nightly releases, otherwise only count Stable releases. + /// + /// Only count Stable releases + /// + public async Task GetNumberOfReleasesBehind(bool stableOnly = false) { var updates = await GetAllReleases(); - return updates.TakeWhile(update => update.UpdateVersion != update.CurrentVersion).Count(); + + // If the user is on nightly, then we need to handle releases behind differently + if (!stableOnly && (updates[0].IsPrerelease || updates[0].IsOnNightlyInRelease)) + { + return updates.Count(u => u.IsReleaseNewer); + } + + return updates + .Where(update => !update.IsPrerelease) + .Count(u => u.IsReleaseNewer); + } + + /// + /// Clears the Github cache + /// + public void BustGithubCache() + { + try + { + File.Delete(_cacheFilePath); + File.Delete(_cacheLatestReleaseFilePath); + } catch (Exception ex) + { + _logger.LogError(ex, "Failed to clear Github cache"); + } } private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) @@ -135,18 +468,33 @@ public class VersionUpdaterService : IVersionUpdaterService var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty)); var currentVersion = BuildInfo.Version.ToString(4); + var bodyHtml = _markdown.Transform(update.Body.Trim()); + var parsedSections = ParseReleaseBody(update.Body); + var blogPart = _markdown.Transform(ExtractBlogPart(update.Body).Trim()); return new UpdateNotificationDto() { CurrentVersion = currentVersion, UpdateVersion = updateVersion.ToString(), - UpdateBody = _markdown.Transform(update.Body.Trim()), + UpdateBody = bodyHtml, UpdateTitle = update.Name, UpdateUrl = update.Html_Url, IsDocker = OsInfo.IsDocker, PublishDate = update.Published_At, IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion), IsReleaseNewer = BuildInfo.Version < updateVersion, + IsPrerelease = false, + + Added = parsedSections.TryGetValue("Added", out var added) ? added : [], + Removed = parsedSections.TryGetValue("Removed", out var removed) ? removed : [], + Changed = parsedSections.TryGetValue("Changed", out var changed) ? changed : [], + Fixed = parsedSections.TryGetValue("Fixed", out var fixes) ? fixes : [], + Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [], + Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [], + KnownIssues = parsedSections.TryGetValue("Known Issues", out var knownIssues) ? knownIssues : [], + Api = parsedSections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = parsedSections.TryGetValue("Feature Requests", out var frs) ? frs : [], + BlogPart = blogPart }; } @@ -165,6 +513,26 @@ public class VersionUpdaterService : IVersionUpdaterService } } + private async Task GetVersionFromCommit(string commitSha) + { + try + { + // Use the raw GitHub URL format for the csproj file + var content = await $"https://raw.githubusercontent.com/Kareadita/Kavita/{commitSha}/Kavita.Common/Kavita.Common.csproj" + .WithHeader("User-Agent", "Kavita") + .GetStringAsync(); + + var versionMatch = Regex.Match(content, @"([0-9\.]+)"); + return versionMatch.Success ? versionMatch.Groups[1].Value : null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get version from commit {Sha}: {Message}", commitSha, ex.Message); + return null; + } + } + + private static async Task GetGithubRelease() { @@ -176,13 +544,109 @@ public class VersionUpdaterService : IVersionUpdaterService return update; } - private static async Task> GetGithubReleases() + private static async Task> GetGithubReleases() { var update = await GithubAllReleasesUrl .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") - .GetJsonAsync>(); + .GetJsonAsync>(); return update; } + + private static string ExtractBlogPart(string body) + { + if (body.StartsWith('#')) return string.Empty; + var match = BlogPartRegex().Match(body); + return match.Success ? match.Groups[1].Value.Trim() : body.Trim(); + } + + private static Dictionary> ParseReleaseBody(string body) + { + var sections = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var lines = body.Split('\n'); + string? currentSection = null; + + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + + // Check for section headers (case-insensitive) + if (trimmedLine.StartsWith('#')) + { + currentSection = trimmedLine.TrimStart('#').Trim(); + sections[currentSection] = []; + continue; + } + + // Parse items under a section + if (currentSection != null && + trimmedLine.StartsWith("- ") && + !string.IsNullOrWhiteSpace(trimmedLine)) + { + // Remove "Fixed:", "Added:" etc. if present + var cleanedItem = CleanSectionItem(trimmedLine); + + // Some sections like API/Developer/Removed don't have the title repeated, so we need to check for an additional cleaning + if (cleanedItem.StartsWith("- ")) + { + cleanedItem = trimmedLine.Substring(2); + } + + // Only add non-empty items + if (!string.IsNullOrWhiteSpace(cleanedItem)) + { + sections[currentSection].Add(cleanedItem); + } + } + } + + return sections; + } + + private static string CleanSectionItem(string item) + { + // Remove everything up to and including the first ":" + var colonIndex = item.IndexOf(':'); + if (colonIndex != -1) + { + item = item.Substring(colonIndex + 1).Trim(); + } + + return item; + } + + private sealed class PullRequestInfo + { + public required string Title { get; init; } + public required string Body { get; init; } + public required string Html_Url { get; init; } + public required string Merged_At { get; init; } + public required int Number { get; init; } + } + + private sealed class CommitInfo + { + public required string Sha { get; init; } + public required CommitDetail Commit { get; init; } + public required string Html_Url { get; init; } + } + + private sealed class CommitDetail + { + public required string Message { get; init; } + public required CommitAuthor Author { get; init; } + } + + private sealed class CommitAuthor + { + public required string Date { get; init; } + } + + private sealed class NightlyInfo + { + public required string Version { get; init; } + public required int PrNumber { get; init; } + public required DateTime Date { get; init; } + } } diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index cc998de3d..720d97663 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -4,10 +4,12 @@ using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; +using System.Threading; using System.Threading.Tasks; using API.Data; using API.DTOs.Account; using API.Entities; +using API.Helpers; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -35,6 +37,7 @@ public class TokenService : ITokenService private readonly IUnitOfWork _unitOfWork; private readonly SymmetricSecurityKey _key; private const string RefreshTokenName = "RefreshToken"; + private static readonly SemaphoreSlim _refreshTokenLock = new SemaphoreSlim(1, 1); public TokenService(IConfiguration config, UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) { @@ -80,6 +83,8 @@ public class TokenService : ITokenService public async Task ValidateRefreshToken(TokenRequestDto request) { + await _refreshTokenLock.WaitAsync(); + try { var tokenHandler = new JwtSecurityTokenHandler(); @@ -90,6 +95,7 @@ public class TokenService : ITokenService _logger.LogDebug("[RefreshToken] failed to validate due to not finding user in RefreshToken"); return null; } + var user = await _userManager.FindByNameAsync(username); if (user == null) { @@ -97,13 +103,19 @@ public class TokenService : ITokenService return null; } - var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken); + var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, + RefreshTokenName, request.RefreshToken); if (!validated && tokenContent.ValidTo <= DateTime.UtcNow.Add(TimeSpan.FromHours(1))) { _logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token"); return null; } + // Remove the old refresh token first + await _userManager.RemoveAuthenticationTokenAsync(user, + TokenOptions.DefaultProvider, + RefreshTokenName); + try { user.UpdateLastActive(); @@ -120,7 +132,8 @@ public class TokenService : ITokenService Token = await CreateToken(user), RefreshToken = await CreateRefreshToken(user) }; - } catch (SecurityTokenExpiredException ex) + } + catch (SecurityTokenExpiredException ex) { // Handle expired token _logger.LogError(ex, "Failed to validate refresh token"); @@ -132,6 +145,10 @@ public class TokenService : ITokenService _logger.LogError(ex, "Failed to validate refresh token"); return null; } + finally + { + _refreshTokenLock.Release(); + } } public async Task GetJwtFromUser(AppUser user) @@ -143,12 +160,12 @@ public class TokenService : ITokenService public static bool HasTokenExpired(string? token) { - if (string.IsNullOrEmpty(token)) return true; + return !JwtHelper.IsTokenValid(token); + } - var tokenHandler = new JwtSecurityTokenHandler(); - var tokenContent = tokenHandler.ReadJwtToken(token); - var validToUtc = tokenContent.ValidTo.ToUniversalTime(); - return validToUtc < DateTime.UtcNow; + public static DateTime GetTokenExpiry(string? token) + { + return JwtHelper.GetTokenExpiry(token); } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index de9818b79..87a464e6a 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,5 +1,6 @@ using System; using API.DTOs.Update; +using API.Entities.Person; using API.Extensions; using API.Services.Plus; @@ -147,6 +148,14 @@ public static class MessageFactory /// Volume is removed from server /// public const string VolumeRemoved = "VolumeRemoved"; + /// + /// A Person merged has been merged into another + /// + public const string PersonMerged = "PersonMerged"; + /// + /// A Rate limit error was hit when matching a series with Kavita+ + /// + public const string ExternalMatchRateLimitError = "ExternalMatchRateLimitError"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -661,4 +670,29 @@ public static class MessageFactory EventType = ProgressEventType.Single, }; } + + public static SignalRMessage PersonMergedMessage(Person dst, Person src) + { + return new SignalRMessage() + { + Name = PersonMerged, + Body = new + { + srcId = src.Id, + dstName = dst.Name, + }, + }; + } + public static SignalRMessage ExternalMatchRateLimitErrorEvent(int seriesId, string seriesName) + { + return new SignalRMessage() + { + Name = ExternalMatchRateLimitError, + Body = new + { + seriesId = seriesId, + seriesName = seriesName, + }, + }; + } } diff --git a/API/Startup.cs b/API/Startup.cs index cf6c7ccfd..f57cb7d01 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -41,6 +41,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using Microsoft.OpenApi.Models; using Serilog; +using Swashbuckle.AspNetCore.SwaggerGen; using TaskScheduler = API.Services.TaskScheduler; namespace API; @@ -54,6 +55,9 @@ public class Startup { _config = config; _env = env; + + // Disable Hangfire Automatic Retry + GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 0 }); } // This method gets called by the runtime. Use this method to add services to the container. @@ -137,9 +141,9 @@ public class Startup { c.SwaggerDoc("v1", new OpenApiInfo { - Version = "3.1.0", - Title = "Kavita", - Description = $"Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v{BuildInfo.Version.ToString()}", + Version = BuildInfo.Version.ToString(), + Title = $"Kavita", + Description = $"Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v{BuildInfo.Version}", License = new OpenApiLicense { Name = "GPL-3.0", @@ -222,7 +226,7 @@ public class Startup // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService, - IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService) + IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService, IVersionUpdaterService versionService) { var logger = serviceProvider.GetRequiredService>(); @@ -234,9 +238,10 @@ public class Startup // Apply all migrations on startup var dataContext = serviceProvider.GetRequiredService(); - logger.LogInformation("Running Migrations"); + #region Migrations + // v0.7.9 await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger); @@ -277,13 +282,37 @@ public class Startup await MigrateDuplicateDarkTheme.Migrate(dataContext, logger); await ManualMigrateUnscrobbleBookLibraries.Migrate(dataContext, logger); + // v0.8.5 + await ManualMigrateBlacklistTableToSeries.Migrate(dataContext, logger); + await ManualMigrateInvalidBlacklistSeries.Migrate(dataContext, logger); + await ManualMigrateScrobbleErrors.Migrate(dataContext, logger); + await ManualMigrateNeedsManualMatch.Migrate(dataContext, logger); + await MigrateProgressExportForV085.Migrate(dataContext, directoryService, logger); + + // v0.8.6 + await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger); + await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger); + + // v0.8.7 + await ManualMigrateReadingProfiles.Migrate(dataContext, logger); + + #endregion + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); + var isVersionDifferent = installVersion.Value != BuildInfo.Version.ToString(); installVersion.Value = BuildInfo.Version.ToString(); unitOfWork.SettingsRepository.Update(installVersion); await unitOfWork.CommitAsync(); logger.LogInformation("Running Migrations - complete"); + + if (isVersionDifferent) + { + // Clear the Github cache so update stuff shows correctly + versionService.BustGithubCache(); + } + }).GetAwaiter() .GetResult(); } diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 83a13e7ce..ad2d89fa5 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -1,8 +1,8 @@ { "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, - "IpAddresses": "0.0.0.0,::", - "BaseUrl": "/test/", + "IpAddresses": "", + "BaseUrl": "/", "Cache": 75, "AllowIFraming": false } \ No newline at end of file diff --git a/API/config/appsettings.json b/API/config/appsettings.json index 3eeee1c18..c77ff6a30 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -3,5 +3,5 @@ "Port": 5000, "IpAddresses": "", "BaseUrl": "/", - "Cache": 50 + "Cache": 75 } diff --git a/API/config/templates/EmailPasswordReset.html b/API/config/templates/EmailPasswordReset.html index 2486d3a60..7ac7dc315 100644 --- a/API/config/templates/EmailPasswordReset.html +++ b/API/config/templates/EmailPasswordReset.html @@ -199,7 +199,7 @@
-
Email confirmation is required for continued access. Click the button to confirm your email.
+
{{Preheader}}
+ + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
{{Preheader}}
+ + + +
+ + + diff --git a/API/config/templates/TokenExpiringSoon.html b/API/config/templates/TokenExpiringSoon.html new file mode 100644 index 000000000..eac990260 --- /dev/null +++ b/API/config/templates/TokenExpiringSoon.html @@ -0,0 +1,344 @@ + + + + + + + + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
{{Preheader}}
+ + + +
+ + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43cd2e208..292217862 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit - HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc) - [Git](https://git-scm.com/downloads) - [NodeJS](https://nodejs.org/en/download/) (Node 18.13.X or higher) -- .NET 8.0+ +- .NET 9.0+ - dotnet tool install -g Swashbuckle.AspNetCore.Cli ### Getting started ### diff --git a/Dockerfile b/Dockerfile index 6d52acaba..bfc253c0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN mkdir /files COPY _output/*.tar.gz /files/ COPY UI/Web/dist/browser /files/wwwroot COPY copy_runtime.sh /copy_runtime.sh + +RUN chmod +x /copy_runtime.sh RUN /copy_runtime.sh RUN chmod +x /Kavita/Kavita @@ -34,6 +36,7 @@ WORKDIR /kavita HEALTHCHECK --interval=30s --timeout=15s --start-period=30s --retries=3 CMD curl -fsS http://localhost:5000/api/health || exit 1 +# Enable detection of running in a container ENV DOTNET_RUNNING_IN_CONTAINER=true ENTRYPOINT [ "/bin/bash" ] diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index ca0fc40ec..ba4fd09b7 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -16,7 +16,9 @@ public static class Configuration public const long DefaultCacheMemory = 75; private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); - public static string KavitaPlusApiUrl = "https://plus.kavitareader.com"; + public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development + ? "https://plus.kavitareader.com" : "https://plus.kavitareader.com"; // http://localhost:5020 + public static readonly string StatsApiUrl = "https://stats.kavitareader.com"; public static int Port { @@ -314,6 +316,7 @@ public static class Configuration { public string TokenKey { get; set; } // ReSharper disable once MemberHidesStaticFromOuterClass +#pragma warning disable S3218 public int Port { get; set; } = DefaultHttpPort; // ReSharper disable once MemberHidesStaticFromOuterClass public string IpAddresses { get; set; } = string.Empty; @@ -322,6 +325,7 @@ public static class Configuration // ReSharper disable once MemberHidesStaticFromOuterClass public long Cache { get; set; } = DefaultCacheMemory; // ReSharper disable once MemberHidesStaticFromOuterClass - public bool AllowIFraming { get; set; } = false; + public bool AllowIFraming { get; init; } = false; +#pragma warning restore S3218 } } diff --git a/Kavita.Common/Helpers/CronHelper.cs b/Kavita.Common/Helpers/CronHelper.cs index 77a4e934e..0b40113ce 100644 --- a/Kavita.Common/Helpers/CronHelper.cs +++ b/Kavita.Common/Helpers/CronHelper.cs @@ -13,7 +13,7 @@ public static class CronHelper CronExpression.Parse(cronExpression); return true; } - catch (Exception ex) + catch (Exception) { /* Swallow */ return false; diff --git a/Kavita.Common/Helpers/FlurlConfiguration.cs b/Kavita.Common/Helpers/FlurlConfiguration.cs new file mode 100644 index 000000000..b80dff8d9 --- /dev/null +++ b/Kavita.Common/Helpers/FlurlConfiguration.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Flurl.Http; + +namespace Kavita.Common.Helpers; + +/// +/// Helper class for configuring Flurl client for a specific URL. +/// +public static class FlurlConfiguration +{ + private static readonly List ConfiguredClients = new List(); + private static readonly Lock Lock = new Lock(); + + /// + /// Configures the Flurl client for the specified URL. + /// + /// The URL to configure the client for. + public static void ConfigureClientForUrl(string url) + { + //Important client are mapped without path, per example two urls pointing to the same host:port but different path, will use the same client. + lock (Lock) + { + var ur = new Uri(url); + //key is host:port + var host = ur.Host + ":" + ur.Port; + if (ConfiguredClients.Contains(host)) return; + + FlurlHttp.ConfigureClientForUrl(url).ConfigureInnerHandler(cli => +#pragma warning disable S4830 + cli.ServerCertificateCustomValidationCallback = (_, _, _, _) => true); +#pragma warning restore S4830 + + ConfiguredClients.Add(host); + } + } +} diff --git a/Kavita.Common/Helpers/UntrustedCertClientFactory.cs b/Kavita.Common/Helpers/UntrustedCertClientFactory.cs deleted file mode 100644 index 6ddb2a9f3..000000000 --- a/Kavita.Common/Helpers/UntrustedCertClientFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Net.Http; -using Flurl.Http.Configuration; - -namespace Kavita.Common.Helpers; - -public class UntrustedCertClientFactory : DefaultHttpClientFactory -{ - public override HttpMessageHandler CreateMessageHandler() { - return new HttpClientHandler { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - } -} diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 514944aa9..c7dd0ab94 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -1,23 +1,23 @@ - net8.0 + net9.0 kavitareader.com Kavita - 0.8.4.4 + 0.8.7.1 en true - + - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/Kavita.sln.DotSettings b/Kavita.sln.DotSettings index 92adaa72f..b46c328cd 100644 --- a/Kavita.sln.DotSettings +++ b/Kavita.sln.DotSettings @@ -2,9 +2,13 @@ ExplicitlyExcluded True True + True + True + True True True True + True True True True diff --git a/README.md b/README.md index 0c083b900..ffff8d831 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ your reading collection with your friends and family! - Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc - Ability to customize your dashboard and side nav with smart filters, custom order and visibility toggles - Full Localization Support +- Ability to download metadata (available via [Kavita+](https://wiki.kavitareader.com/kavita+)) ## Support @@ -40,7 +41,7 @@ your reading collection with your friends and family! ## Demo If you want to try out Kavita, a demo is available: -[https://demo.kavitareader.com/](https://demo.kavitareader.com/) +[https://demo.kavitareader.com/](https://demo.kavitareader.com/login?apiKey=9003cf99-9213-4206-a787-af2fe4cc5f1f) ``` Username: demouser Password: Demouser64 @@ -49,7 +50,7 @@ Password: Demouser64 ## Setup The easiest way to get started is to visit our Wiki which has up-to-date information on a variety of install methods and platforms. -[https://wiki.kavitareader.com/installation/getting-started](https://wiki.kavitareader.com/installation/getting-started) +[https://wiki.kavitareader.com/getting-started](https://wiki.kavitareader.com/getting-started) ## Feature Requests Got a great idea? Throw it up on [Discussions](https://github.com/Kareadita/Kavita/discussions/2529) or vote on another idea. Many great features in Kavita are driven by our community. @@ -106,13 +107,10 @@ Support this project by becoming a sponsor. Your logo will show up here with a l ## Mega Sponsors -## JetBrains -Thank you to [ JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. - -* [ Rider](http://www.jetbrains.com/rider/) +## Powered By +[![JetBrains logo.](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://jb.gg/OpenSource) ### License - * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) * Copyright 2020-2024 diff --git a/TestData b/TestData deleted file mode 160000 index 4f5750025..000000000 --- a/TestData +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4f5750025a1c0b48cd72eaa6f1b61642c41f147f diff --git a/UI/Web/.editorconfig b/UI/Web/.editorconfig index 2c6908b84..28045b9af 100644 --- a/UI/Web/.editorconfig +++ b/UI/Web/.editorconfig @@ -8,6 +8,12 @@ indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true +[*.json] +indent_size = 2 + +[en.json] +indent_size = 4 + [*.html] indent_size = 2 diff --git a/UI/Web/.gitignore b/UI/Web/.gitignore index 92096907b..8132126c9 100644 --- a/UI/Web/.gitignore +++ b/UI/Web/.gitignore @@ -2,6 +2,4 @@ node_modules/ test-results/ playwright-report/ i18n-cache-busting.json -/playwright-report/ -/blob-report/ -/playwright/.cache/ +e2e-tests/environments/environment.local.ts diff --git a/UI/Web/angular.json b/UI/Web/angular.json index a45f8d9bf..1ce56fa2e 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -56,7 +56,12 @@ }, "extractLicenses": false, "optimization": false, - "namedChunks": true + "namedChunks": true, + "stylePreprocessorOptions": { + "sass": { + "silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"] + } + } }, "configurations": { "production": { diff --git a/UI/Web/e2e-tests/environment.ts b/UI/Web/e2e-tests/environment.ts deleted file mode 100644 index 0c16ed522..000000000 --- a/UI/Web/e2e-tests/environment.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * This is public information - create a environment.local.ts file and use admin account there - */ -export const environment = { - baseUrl: 'https://demo.kavitareader.com/', - username: 'demouser', - password: 'Demouser64', -}; diff --git a/UI/Web/e2e-tests/example.spec.ts b/UI/Web/e2e-tests/example.spec.ts deleted file mode 100644 index 54a906a4e..000000000 --- a/UI/Web/e2e-tests/example.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('has title', async ({ page }) => { - await page.goto('https://playwright.dev/'); - - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwright/); -}); - -test('get started link', async ({ page }) => { - await page.goto('https://playwright.dev/'); - - // Click the get started link. - await page.getByRole('link', { name: 'Get started' }).click(); - - // Expects page to have a heading with the name of Installation. - await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); -}); diff --git a/UI/Web/e2e-tests/pages/login-page.ts b/UI/Web/e2e-tests/pages/login-page.ts deleted file mode 100644 index 9501081e7..000000000 --- a/UI/Web/e2e-tests/pages/login-page.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Page } from '@playwright/test'; - -export class LoginPage { - readonly page: Page; - - constructor(page: Page) { - this.page = page; - } - - async navigate() { - await this.page.goto('/login'); - } - - async login(username: string, password: string) { - await this.page.fill('input[formControlName="username"]', username); - await this.page.fill('input[formControlName="password"]', password); - await this.page.click('button[type="submit"]'); - } -} diff --git a/UI/Web/e2e-tests/tests/Login/login.spec.ts b/UI/Web/e2e-tests/tests/Login/login.spec.ts deleted file mode 100644 index fc5ce626c..000000000 --- a/UI/Web/e2e-tests/tests/Login/login.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { LoginPage } from 'e2e-tests/pages/login-page'; -import {environment} from "../../environment"; - - -test('has title', async ({ page }) => { - await page.goto(environment.baseUrl); - - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Kavita/); -}); - -test('login functionality', async ({ page }) => { - // Navigate to the login page - await page.goto(environment.baseUrl); - - // Verify the page title - await expect(page).toHaveTitle(/Kavita/); - - const loginPage = new LoginPage(page); - //await loginPage.navigate(); - await loginPage.login(environment.username, environment.password); - - // Verify successful login by checking for Home on side nav - await expect(page.locator('#null')).toBeVisible(); -}); diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 35486bc5c..cfce8cded 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -8,66 +8,66 @@ "name": "kavita-webui", "version": "0.7.12.1", "dependencies": { - "@angular-slider/ngx-slider": "^18.0.0", - "@angular/animations": "^18.2.9", - "@angular/cdk": "^18.2.10", - "@angular/common": "^18.2.9", - "@angular/compiler": "^18.2.9", - "@angular/core": "^18.2.9", - "@angular/forms": "^18.2.9", - "@angular/localize": "^18.2.9", - "@angular/platform-browser": "^18.2.9", - "@angular/platform-browser-dynamic": "^18.2.9", - "@angular/router": "^18.2.9", - "@fortawesome/fontawesome-free": "^6.6.0", - "@iharbeck/ngx-virtual-scroller": "^17.0.2", - "@iplab/ngx-file-upload": "^18.0.0", - "@jsverse/transloco": "^7.5.0", + "@angular-slider/ngx-slider": "^19.0.0", + "@angular/animations": "^19.2.5", + "@angular/cdk": "^19.2.8", + "@angular/common": "^19.2.5", + "@angular/compiler": "^19.2.5", + "@angular/core": "^19.2.5", + "@angular/forms": "^19.2.5", + "@angular/localize": "^19.2.5", + "@angular/platform-browser": "^19.2.5", + "@angular/platform-browser-dynamic": "^19.2.5", + "@angular/router": "^19.2.5", + "@fortawesome/fontawesome-free": "^6.7.2", + "@iharbeck/ngx-virtual-scroller": "^19.0.1", + "@iplab/ngx-file-upload": "^19.0.3", + "@jsverse/transloco": "^7.6.1", "@jsverse/transloco-locale": "^7.0.1", "@jsverse/transloco-persist-lang": "^7.0.2", "@jsverse/transloco-persist-translations": "^7.0.1", "@jsverse/transloco-preload-langs": "^7.0.1", "@microsoft/signalr": "^8.0.7", - "@ng-bootstrap/ng-bootstrap": "^17.0.1", + "@ng-bootstrap/ng-bootstrap": "^18.0.0", "@popperjs/core": "^2.11.7", - "@swimlane/ngx-charts": "^20.5.0", - "@tweenjs/tween.js": "^23.1.3", + "@siemens/ngx-datatable": "^22.4.1", + "@swimlane/ngx-charts": "^22.0.0-alpha.0", + "@tweenjs/tween.js": "^25.0.0", "bootstrap": "^5.3.2", "charts.css": "^1.1.0", "file-saver": "^2.0.5", - "luxon": "^3.5.0", + "luxon": "^3.6.1", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", - "ng-select2-component": "^14.0.1", - "ngx-color-picker": "^17.0.0", - "ngx-extended-pdf-viewer": "^21.4.6", + "ng-select2-component": "^17.2.4", + "ngx-color-picker": "^19.0.0", + "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-file-drop": "^16.0.0", "ngx-stars": "^1.6.5", "ngx-toastr": "^19.0.0", "nosleep.js": "^0.12.0", - "rxjs": "^7.8.0", + "rxjs": "^7.8.2", "screenfull": "^6.0.2", "swiper": "^8.4.6", - "tslib": "^2.8.0", - "zone.js": "^0.14.10" + "tslib": "^2.8.1", + "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-eslint/builder": "^18.4.0", - "@angular-eslint/eslint-plugin": "^18.4.0", - "@angular-eslint/eslint-plugin-template": "^18.4.0", - "@angular-eslint/schematics": "^18.4.0", - "@angular-eslint/template-parser": "^18.4.0", - "@angular/build": "^18.2.10", - "@angular/cli": "^18.2.10", - "@angular/compiler-cli": "^18.2.9", - "@playwright/test": "^1.49.0", + "@angular-eslint/builder": "^19.3.0", + "@angular-eslint/eslint-plugin": "^19.3.0", + "@angular-eslint/eslint-plugin-template": "^19.3.0", + "@angular-eslint/schematics": "^19.3.0", + "@angular-eslint/template-parser": "^19.3.0", + "@angular/build": "^19.2.6", + "@angular/cli": "^19.2.6", + "@angular/compiler-cli": "^19.2.5", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", - "@types/luxon": "^3.4.0", - "@types/node": "^22.8.0", - "@typescript-eslint/eslint-plugin": "^8.11.0", - "@typescript-eslint/parser": "^8.11.0", - "eslint": "^8.57.0", + "@types/luxon": "^3.6.2", + "@types/node": "^22.13.13", + "@typescript-eslint/eslint-plugin": "^8.28.0", + "@typescript-eslint/parser": "^8.28.0", + "eslint": "^9.23.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", "ts-node": "~10.9.1", @@ -97,12 +97,12 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1802.10", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.10.tgz", - "integrity": "sha512-/xudcHK2s4J/GcL6qyobmGaWMHQcYLSMqCaWMT+nK6I6tu9VEAj/p3R83Tzx8B/eKi31Pz499uHw9pmqdtbafg==", + "version": "0.1902.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.6.tgz", + "integrity": "sha512-Dx6yPxpaE5AhP6UtrVRDCc9Ihq9B65LAbmIh3dNOyeehratuaQS0TYNKjbpaevevJojW840DTg80N+CrlfYp9g==", "dev": true, "dependencies": { - "@angular-devkit/core": "18.2.10", + "@angular-devkit/core": "19.2.6", "rxjs": "7.8.1" }, "engines": { @@ -111,10 +111,19 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-devkit/architect/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/@angular-devkit/core": { - "version": "18.2.10", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.10.tgz", - "integrity": "sha512-LFqiNdraBujg8e1lhuB0bkFVAoIbVbeXXwfoeROKH60OPbP8tHdgV6sFTqU7UGBKA+b+bYye70KFTG2Ys8QzKQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", + "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", "dev": true, "dependencies": { "ajv": "8.17.1", @@ -130,7 +139,7 @@ "yarn": ">= 1.13.0" }, "peerDependencies": { - "chokidar": "^3.5.2" + "chokidar": "^4.0.0" }, "peerDependenciesMeta": { "chokidar": { @@ -138,32 +147,24 @@ } } }, - "node_modules/@angular-devkit/core/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dev": true, "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "tslib": "^2.1.0" } }, "node_modules/@angular-devkit/schematics": { - "version": "18.2.10", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.10.tgz", - "integrity": "sha512-EIm/yCYg3ZYPsPYJxXRX5F6PofJCbNQ5rZEuQEY09vy+ZRTqGezH0qoUP5WxlYeJrjiRLYqADI9WtVNzDyaD4w==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.6.tgz", + "integrity": "sha512-YTAxNnT++5eflx19OUHmOWu597/TbTel+QARiZCv1xQw99+X8DCKKOUXtqBRd53CAHlREDI33Rn/JLY3NYgMLQ==", "dev": true, "dependencies": { - "@angular-devkit/core": "18.2.10", + "@angular-devkit/core": "19.2.6", "jsonc-parser": "3.3.1", - "magic-string": "0.30.11", + "magic-string": "0.30.17", "ora": "5.4.1", "rxjs": "7.8.1" }, @@ -173,30 +174,43 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@angular-eslint/builder": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-18.4.0.tgz", - "integrity": "sha512-FOzGHX/nHSV1wSduSsabsx3aqC1nfde0opEpEDSOJhxExDxKCwoS1XPy1aERGyKip4ZVA6phC3dLtoBH3QMkVQ==", + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-eslint/builder": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-19.3.0.tgz", + "integrity": "sha512-j9xNrzZJq29ONSG6EaeQHve0Squkm6u6Dm8fZgWP7crTFOrtLXn7Wxgxuyl9eddpbWY1Ov1gjFuwBVnxIdyAqg==", + "dev": true, + "dependencies": { + "@angular-devkit/architect": ">= 0.1900.0 < 0.2000.0", + "@angular-devkit/core": ">= 19.0.0 < 20.0.0" + }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": "*" } }, "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-18.4.0.tgz", - "integrity": "sha512-HlFHt2qgdd+jqyVIkCXmrjHauXo/XY3Rp0UNabk83ejGi/raM/6lEFI7iFWzHxLyiAKk4OgGI5W26giSQw991A==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.3.0.tgz", + "integrity": "sha512-63Zci4pvnUR1iSkikFlNbShF1tO5HOarYd8fvNfmOZwFfZ/1T3j3bCy9YbE+aM5SYrWqPaPP/OcwZ3wJ8WNvqA==", "dev": true }, "node_modules/@angular-eslint/eslint-plugin": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-18.4.0.tgz", - "integrity": "sha512-Saz9lkWPN3da7ZKW17UsOSN7DeY+TPh+wz/6GCNZCh67Uw2wvMC9agb+4hgpZNXYCP5+u7erqzxQmBoWnS/A+A==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.3.0.tgz", + "integrity": "sha512-nBLslLI20KnVbqlfNW7GcnI9R6cYCvRGjOE2QYhzxM316ciAQ62tvQuXP9ZVnRBLSKDAVnMeC0eTq9O4ysrxrQ==", "dev": true, "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.4.0", - "@angular-eslint/utils": "18.4.0" + "@angular-eslint/bundled-angular-compiler": "19.3.0", + "@angular-eslint/utils": "19.3.0" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", @@ -205,13 +219,13 @@ } }, "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-18.4.0.tgz", - "integrity": "sha512-n3uZFCy76DnggPqjSVFV3gYD1ik7jCG28o2/HO4kobcMNKnwW8XAlFUagQ4TipNQh7fQiAefsEqvv2quMsYDVw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.3.0.tgz", + "integrity": "sha512-WyouppTpOYut+wvv13wlqqZ8EHoDrCZxNfGKuEUYK1BPmQlTB8EIZfQH4iR1rFVS28Rw+XRIiXo1x3oC0SOfnA==", "dev": true, "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.4.0", - "@angular-eslint/utils": "18.4.0", + "@angular-eslint/bundled-angular-compiler": "19.3.0", + "@angular-eslint/utils": "19.3.0", "aria-query": "5.3.2", "axobject-query": "4.1.0" }, @@ -222,58 +236,37 @@ "typescript": "*" } }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@angular-eslint/utils": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-18.4.0.tgz", - "integrity": "sha512-At1yS8GRviGBoaupiQwEOL4/IcZJCE/+2vpXdItMWPGB1HWetxlKAUZTMmIBX/r5Z7CoXxl+LbqpGhrhyzIQAg==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.4.0" - }, - "peerDependencies": { - "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/@angular-eslint/utils": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-18.4.0.tgz", - "integrity": "sha512-At1yS8GRviGBoaupiQwEOL4/IcZJCE/+2vpXdItMWPGB1HWetxlKAUZTMmIBX/r5Z7CoXxl+LbqpGhrhyzIQAg==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.4.0" - }, - "peerDependencies": { - "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "*" - } - }, "node_modules/@angular-eslint/schematics": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-18.4.0.tgz", - "integrity": "sha512-ssqe+0YCfekbWIXNdCrHfoPK/bPZAWybs0Bn/b99dfd8h8uyXkERo9AzIOx4Uyj/08SkP9aPL/0uOOEHDsRGwQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-19.3.0.tgz", + "integrity": "sha512-Wl5sFQ4t84LUb8mJ2iVfhYFhtF55IugXu7rRhPHtgIu9Ty5s1v3HGUx4LKv51m2kWhPPeFOTmjeBv1APzFlmnQ==", "dev": true, "dependencies": { - "@angular-eslint/eslint-plugin": "18.4.0", - "@angular-eslint/eslint-plugin-template": "18.4.0", - "ignore": "5.3.2", - "semver": "7.6.3", + "@angular-devkit/core": ">= 19.0.0 < 20.0.0", + "@angular-devkit/schematics": ">= 19.0.0 < 20.0.0", + "@angular-eslint/eslint-plugin": "19.3.0", + "@angular-eslint/eslint-plugin-template": "19.3.0", + "ignore": "7.0.3", + "semver": "7.7.1", "strip-json-comments": "3.1.1" - }, - "peerDependencies": { - "@angular-devkit/core": ">= 18.0.0 < 19.0.0", - "@angular-devkit/schematics": ">= 18.0.0 < 19.0.0" + } + }, + "node_modules/@angular-eslint/schematics/node_modules/ignore": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", + "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", + "dev": true, + "engines": { + "node": ">= 4" } }, "node_modules/@angular-eslint/template-parser": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-18.4.0.tgz", - "integrity": "sha512-VTep3Xd3IOaRIPL+JN/TV4/2DqUPbjtF3TNY15diD/llnrEhqFnmsvMihexbQyTqzOG+zU554oK44YfvAtHOrw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.3.0.tgz", + "integrity": "sha512-VxMNgsHXMWbbmZeBuBX5i8pzsSSEaoACVpaE+j8Muk60Am4Mxc0PytJm4n3znBSvI3B7Kq2+vStSRYPkOER4lA==", "dev": true, "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.4.0", + "@angular-eslint/bundled-angular-compiler": "19.3.0", "eslint-scope": "^8.0.2" }, "peerDependencies": { @@ -281,25 +274,39 @@ "typescript": "*" } }, + "node_modules/@angular-eslint/utils": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.3.0.tgz", + "integrity": "sha512-ovvbQh96FIJfepHqLCMdKFkPXr3EbcvYc9kMj9hZyIxs/9/VxwPH7x25mMs4VsL6rXVgH2FgG5kR38UZlcTNNw==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.3.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, "node_modules/@angular-slider/ngx-slider": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/@angular-slider/ngx-slider/-/ngx-slider-18.0.0.tgz", - "integrity": "sha512-QR5zP3B++cLCqUkfg7j65y+4wrt5xPrua6mANoClj2iaoHaAzf3qi+/ANgYTeZ9StoFu22r35uy5Qg0GigjhxQ==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@angular-slider/ngx-slider/-/ngx-slider-19.0.0.tgz", + "integrity": "sha512-VVJ+Fij5SKnbltxh6TdoBAUAKWfCnSLRPZ7e+r2uO88t8qte5/KHqVOdK4DWCjBr3rEr4YrPR4ylqBCuAWPsKQ==", "dependencies": { "detect-passive-events": "^2.0.3", "rxjs": "^7.8.1", "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": "^18.0.0", - "@angular/core": "^18.0.0", - "@angular/forms": "^18.0.0" + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0" } }, "node_modules/@angular/animations": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.9.tgz", - "integrity": "sha512-GAsTKENoTRVKgXX4ACBMMTp8SW4rW8u637uLag+ttJV2XBzC3YJlw5m6b/W4cdrmqZjztoEwUjR6CUTjBqMujQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.5.tgz", + "integrity": "sha512-m4RtY3z1JuHFCh6OrOHxo25oKEigBDdR/XmdCfXIwfTiObZzNA7VQhysgdrb9IISO99kXbjZUYKDtLzgWT8Klg==", "dependencies": { "tslib": "^2.3.0" }, @@ -307,55 +314,64 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.9" + "@angular/common": "19.2.5", + "@angular/core": "19.2.5" } }, "node_modules/@angular/build": { - "version": "18.2.10", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.10.tgz", - "integrity": "sha512-YFBKvAyC5sH17yRYcx7VHCtJ4KUg7xCjCQ4Pe16kiTvW6vuYsgU6Btyti0Qgewd7XaWpTM8hk8N6hE4Z0hpflw==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.6.tgz", + "integrity": "sha512-+VBLb4ZPLswwJmgfsTFzGex+Sq/WveNc+uaIWyHYjwnuI17NXe1qAAg1rlp72CqGn0cirisfOyAUwPc/xZAgTg==", "dev": true, "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.10", - "@babel/core": "7.25.2", - "@babel/helper-annotate-as-pure": "7.24.7", + "@angular-devkit/architect": "0.1902.6", + "@babel/core": "7.26.10", + "@babel/helper-annotate-as-pure": "7.25.9", "@babel/helper-split-export-declaration": "7.24.7", - "@babel/plugin-syntax-import-attributes": "7.24.7", - "@inquirer/confirm": "3.1.22", - "@vitejs/plugin-basic-ssl": "1.1.0", + "@babel/plugin-syntax-import-attributes": "7.26.0", + "@inquirer/confirm": "5.1.6", + "@vitejs/plugin-basic-ssl": "1.2.0", + "beasties": "0.2.0", "browserslist": "^4.23.0", - "critters": "0.0.24", - "esbuild": "0.23.0", - "fast-glob": "3.3.2", - "https-proxy-agent": "7.0.5", - "listr2": "8.2.4", - "lmdb": "3.0.13", - "magic-string": "0.30.11", - "mrmime": "2.0.0", + "esbuild": "0.25.1", + "fast-glob": "3.3.3", + "https-proxy-agent": "7.0.6", + "istanbul-lib-instrument": "6.0.3", + "listr2": "8.2.5", + "magic-string": "0.30.17", + "mrmime": "2.0.1", "parse5-html-rewriting-stream": "7.0.0", "picomatch": "4.0.2", - "piscina": "4.6.1", - "rollup": "4.22.4", - "sass": "1.77.6", - "semver": "7.6.3", - "vite": "5.4.6", - "watchpack": "2.4.1" + "piscina": "4.8.0", + "rollup": "4.34.8", + "sass": "1.85.0", + "semver": "7.7.1", + "source-map-support": "0.5.21", + "vite": "6.2.4", + "watchpack": "2.4.2" }, "engines": { "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, + "optionalDependencies": { + "lmdb": "3.2.6" + }, "peerDependencies": { - "@angular/compiler-cli": "^18.0.0", - "@angular/localize": "^18.0.0", - "@angular/platform-server": "^18.0.0", - "@angular/service-worker": "^18.0.0", + "@angular/compiler": "^19.0.0 || ^19.2.0-next.0", + "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", + "@angular/localize": "^19.0.0 || ^19.2.0-next.0", + "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", + "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", + "@angular/ssr": "^19.2.6", + "karma": "^6.4.0", "less": "^4.2.0", + "ng-packagr": "^19.0.0 || ^19.2.0-next.0", "postcss": "^8.4.0", - "tailwindcss": "^2.0.0 || ^3.0.0", - "typescript": ">=5.4 <5.6" + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "typescript": ">=5.5 <5.9" }, "peerDependenciesMeta": { "@angular/localize": { @@ -367,9 +383,18 @@ "@angular/service-worker": { "optional": true }, + "@angular/ssr": { + "optional": true + }, + "karma": { + "optional": true + }, "less": { "optional": true }, + "ng-packagr": { + "optional": true + }, "postcss": { "optional": true }, @@ -378,43 +403,102 @@ } } }, - "node_modules/@angular/cdk": { - "version": "18.2.10", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.10.tgz", - "integrity": "sha512-Weh0slrfWNp5N6UO4m3tXzs2QBFexNsnJf1dq0oaLDBgfkuqUmxdCkurSv5+lWZRkTPLYmd/hQeJpvrhxMCleg==", + "node_modules/@angular/build/node_modules/@babel/core": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "dev": true, "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/build/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@angular/build/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@angular/build/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@angular/cdk": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.8.tgz", + "integrity": "sha512-ZZqWVYFF80TdjWkk2sc9Pn2luhiYeC78VH3Yjeln4wXMsTGDsvKPBcuOxSxxpJ31saaVBehDjBUuXMqGRj8KuA==", + "dependencies": { + "parse5": "^7.1.2", "tslib": "^2.3.0" }, - "optionalDependencies": { - "parse5": "^7.1.2" - }, "peerDependencies": { - "@angular/common": "^18.0.0 || ^19.0.0", - "@angular/core": "^18.0.0 || ^19.0.0", + "@angular/common": "^19.0.0 || ^20.0.0", + "@angular/core": "^19.0.0 || ^20.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/cli": { - "version": "18.2.10", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.10.tgz", - "integrity": "sha512-qW/F3XVZMzzenFzbn+7FGpw8GOt9qW8UxBtYya7gUNdWlcsgGUk+ZaGC2OLbfI5gX6pchW4TOPMsDSMeaCEI2Q==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.6.tgz", + "integrity": "sha512-eZhFOSsDUHKaciwcWdU5C54ViAvPPdZJf42So93G2vZWDtEq6Uk47huocn1FY9cMhDvURfYLNrrLMpUDtUSsSA==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1802.10", - "@angular-devkit/core": "18.2.10", - "@angular-devkit/schematics": "18.2.10", - "@inquirer/prompts": "5.3.8", - "@listr2/prompt-adapter-inquirer": "2.0.15", - "@schematics/angular": "18.2.10", + "@angular-devkit/architect": "0.1902.6", + "@angular-devkit/core": "19.2.6", + "@angular-devkit/schematics": "19.2.6", + "@inquirer/prompts": "7.3.2", + "@listr2/prompt-adapter-inquirer": "2.0.18", + "@schematics/angular": "19.2.6", "@yarnpkg/lockfile": "1.1.0", - "ini": "4.1.3", + "ini": "5.0.0", "jsonc-parser": "3.3.1", - "listr2": "8.2.4", - "npm-package-arg": "11.0.3", - "npm-pick-manifest": "9.1.0", - "pacote": "18.0.6", - "resolve": "1.22.8", - "semver": "7.6.3", + "listr2": "8.2.5", + "npm-package-arg": "12.0.2", + "npm-pick-manifest": "10.0.0", + "pacote": "20.0.0", + "resolve": "1.22.10", + "semver": "7.7.1", "symbol-observable": "4.0.0", "yargs": "17.7.2" }, @@ -428,9 +512,9 @@ } }, "node_modules/@angular/common": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.9.tgz", - "integrity": "sha512-Opi6DVaU0aGyJqLk5jPmeYx559fp3afj4wuxM5aDzV4KEVGDVbNCpO0hMuwHZ6rtCjHhv1fQthgS48qoiQ6LKw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.5.tgz", + "integrity": "sha512-vFCBdas4C5PxP6ts/4TlRddWD3DUmI3aaO0QZdZvqyLHy428t84ruYdsJXKaeD8ie2U4/9F3a1tsklclRG/BBA==", "dependencies": { "tslib": "^2.3.0" }, @@ -438,35 +522,28 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.9", + "@angular/core": "19.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.9.tgz", - "integrity": "sha512-fchbcbsyTOd/qHGy+yPEmE1p10OTNEjGrWHQzUbf3xdlm23EvxHTitHh8i6EBdwYnM5zz0IIBhltP8tt89oeYw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.5.tgz", + "integrity": "sha512-34J+HubQjwkbZ0AUtU5sa4Zouws9XtP/fKaysMQecoYJTZ3jewzLSRu3aAEZX1Y4gIrcVVKKIxM6oWoXKwYMOA==", "dependencies": { "tslib": "^2.3.0" }, "engines": { "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/core": "18.2.9" - }, - "peerDependenciesMeta": { - "@angular/core": { - "optional": true - } } }, "node_modules/@angular/compiler-cli": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz", - "integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz", + "integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==", + "dev": true, "dependencies": { - "@babel/core": "7.25.2", + "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^4.0.0", "convert-source-map": "^1.5.1", @@ -484,14 +561,15 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.9", - "typescript": ">=5.4 <5.6" + "@angular/compiler": "19.2.5", + "typescript": ">=5.5 <5.9" } }, "node_modules/@angular/compiler-cli/node_modules/chokidar": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -506,6 +584,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -515,9 +594,9 @@ } }, "node_modules/@angular/core": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.9.tgz", - "integrity": "sha512-h9/Bzo/7LTPzzh9I/1Gk8TWOXPGeHt3jLlnYrCh2KbrWbTErNtW0V3ad5I3Zv+K2Z7RSl9Z3D3Y6ILH796N4ZA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.5.tgz", + "integrity": "sha512-NNEz1sEZz1mBpgf6Tz3aJ9b8KjqpTiMYhHfCYA9h9Ipe4D8gUmOsvPHPK2M755OX7p7PmUmzp1XCUHYrZMVHRw==", "dependencies": { "tslib": "^2.3.0" }, @@ -526,13 +605,13 @@ }, "peerDependencies": { "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.14.10" + "zone.js": "~0.15.0" } }, "node_modules/@angular/forms": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.9.tgz", - "integrity": "sha512-yyN5dG60CXH6MRte8rv4aGUTeNOMz/pUV7rVxittpjN7tPHfGEL9Xz89Or90Aa1QiHuBmHFk+9A39s03aO1rDQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.5.tgz", + "integrity": "sha512-2Zvy3qK1kOxiAX9fdSaeG48q7oyO/4RlMYlg1w+ra9qX1SrgwF3OQ2P2Vs+ojg1AxN3z9xFp4aYaaID/G2LZAw==", "dependencies": { "tslib": "^2.3.0" }, @@ -540,20 +619,20 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.9", - "@angular/core": "18.2.9", - "@angular/platform-browser": "18.2.9", + "@angular/common": "19.2.5", + "@angular/core": "19.2.5", + "@angular/platform-browser": "19.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/localize": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.9.tgz", - "integrity": "sha512-CcqyVqV/GyyBe6Cndm2WRM5dyJwjDQ0F7QRGwO3jYWFSYF0h/f0ZjZVH4ra1IX+AwEEicOXW1ig3FBbeOqHPug==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.2.5.tgz", + "integrity": "sha512-oAc19bubk6Z/2Vv6OkV0MsjdgC8cUaUwBmwdc6blFVe1NCX1KjdaqDyC2EQAO3nWfcdV4uvOOuu8myxB64bamw==", "dependencies": { - "@babel/core": "7.25.2", + "@babel/core": "7.26.9", "@types/babel__core": "7.20.5", - "fast-glob": "3.3.2", + "fast-glob": "3.3.3", "yargs": "^17.2.1" }, "bin": { @@ -565,14 +644,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.9", - "@angular/compiler-cli": "18.2.9" + "@angular/compiler": "19.2.5", + "@angular/compiler-cli": "19.2.5" } }, "node_modules/@angular/platform-browser": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.9.tgz", - "integrity": "sha512-UNu6XjK0SV35FFe55yd1yefZI8tzflVKzev/RzC31XngrczhlH0+WCbae4rG1XJULzJwJ1R1p7gqq4+ktEczRQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.5.tgz", + "integrity": "sha512-Lshy++X16cvl6OPvfzMySpsqEaCPKEJmDjz7q7oSt96oxlh6LvOeOUVLjsNyrNaIt9NadpWoqjlu/I9RTPJkpw==", "dependencies": { "tslib": "^2.3.0" }, @@ -580,9 +659,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "18.2.9", - "@angular/common": "18.2.9", - "@angular/core": "18.2.9" + "@angular/animations": "19.2.5", + "@angular/common": "19.2.5", + "@angular/core": "19.2.5" }, "peerDependenciesMeta": { "@angular/animations": { @@ -591,9 +670,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.9.tgz", - "integrity": "sha512-cUTB8Jc3I/fu2UKv/PJmNGQGvKyyTo8ln4GUX3EJ4wUHzgkrU0s4x7DNok0Ql8FZKs5dLR8C0xVbG7Dv/ViPdw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.5.tgz", + "integrity": "sha512-15in8u4552EcdWNTXY2h0MKuJbk3AuXwWr0zVTum4CfB/Ss2tNTrDEdWhgAbhnUI0e9jZQee/fhBbA1rleMYrA==", "dependencies": { "tslib": "^2.3.0" }, @@ -601,16 +680,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.9", - "@angular/compiler": "18.2.9", - "@angular/core": "18.2.9", - "@angular/platform-browser": "18.2.9" + "@angular/common": "19.2.5", + "@angular/compiler": "19.2.5", + "@angular/core": "19.2.5", + "@angular/platform-browser": "19.2.5" } }, "node_modules/@angular/router": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.9.tgz", - "integrity": "sha512-D0rSrMf/sbhr5yQgz+LNBxdv1BR3S4pYDj1Exq6yVRKX8HSbjc5hxe/44VaOEKBh8StJ6GRiNOMoIcDt73Jang==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.5.tgz", + "integrity": "sha512-9pSfmdNXLjaOKj0kd4UxBC7sFdCFOnRGbftp397G3KWqsLsGSKmNFzqhXNeA5QHkaVxnpmpm8HzXU+zYV5JwSg==", "dependencies": { "tslib": "^2.3.0" }, @@ -618,16 +697,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.9", - "@angular/core": "18.2.9", - "@angular/platform-browser": "18.2.9", + "@angular/common": "19.2.5", + "@angular/core": "19.2.5", + "@angular/platform-browser": "19.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@babel/code-frame": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.0.tgz", - "integrity": "sha512-INCKxTtbXtcNbUZ3YXutwMpEleqttcswhAdee7dhuoVrD2cnuc3PqtERBtxkX5nziX9vnBL8WXmSGwv8CuPV6g==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -638,28 +717,28 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.0.tgz", - "integrity": "sha512-qETICbZSLe7uXv9VE8T/RWOdIE5qqyTucOt4zLYMafj2MRO271VGgLd4RACJMeBO37UPWhXiKMBk7YlJ0fOzQA==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", - "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.25.0", - "@babel/helper-compilation-targets": "^7.25.2", - "@babel/helper-module-transforms": "^7.25.2", - "@babel/helpers": "^7.25.0", - "@babel/parser": "^7.25.0", - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.2", - "@babel/types": "^7.25.2", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -688,37 +767,38 @@ } }, "node_modules/@babel/generator": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz", - "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", + "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", "dependencies": { - "@babel/types": "^7.25.0", + "@babel/parser": "^7.26.10", + "@babel/types": "^7.26.10", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", - "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "dependencies": { - "@babel/types": "^7.24.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", "dependencies": { - "@babel/compat-data": "^7.25.9", + "@babel/compat-data": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -765,9 +845,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", "dev": true, "engines": { "node": ">=6.9.0" @@ -810,23 +890,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", + "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.0.tgz", - "integrity": "sha512-aP8x5pIw3xvYr/sXT+SEUwyhrXT8rUJRZltK/qN3Db80dcKpTett8cJxHyjk+xYSVXvNnl2SfcJVjbwxpOSscA==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", + "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", "dependencies": { - "@babel/types": "^7.26.0" + "@babel/types": "^7.26.10" }, "bin": { "parser": "bin/babel-parser.js" @@ -836,12 +916,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz", - "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -851,28 +931,28 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", - "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz", + "integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/generator": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/template": "^7.25.9", - "@babel/types": "^7.25.9", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -880,36 +960,10 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.0.tgz", - "integrity": "sha512-/AIkAmInnWwgEAJGQr9vY0c66Mj6kjkE2ZPB1PurTRaRAh3U+J45sAQMjQDJdh4WbR3l0x5xkimXBKyBXXAu2w==", - "dependencies": { - "@babel/parser": "^7.26.0", - "@babel/types": "^7.26.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@babel/types": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", - "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -950,9 +1004,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", - "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", + "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", "cpu": [ "ppc64" ], @@ -966,9 +1020,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz", - "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", + "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", "cpu": [ "arm" ], @@ -982,9 +1036,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz", - "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", + "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", "cpu": [ "arm64" ], @@ -998,9 +1052,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz", - "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", + "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", "cpu": [ "x64" ], @@ -1014,9 +1068,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", - "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", "cpu": [ "arm64" ], @@ -1030,9 +1084,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz", - "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", + "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", "cpu": [ "x64" ], @@ -1046,9 +1100,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz", - "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", + "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", "cpu": [ "arm64" ], @@ -1062,9 +1116,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz", - "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", + "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", "cpu": [ "x64" ], @@ -1078,9 +1132,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz", - "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", + "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", "cpu": [ "arm" ], @@ -1094,9 +1148,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz", - "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", + "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", "cpu": [ "arm64" ], @@ -1110,9 +1164,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz", - "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", + "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", "cpu": [ "ia32" ], @@ -1126,9 +1180,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz", - "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", + "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", "cpu": [ "loong64" ], @@ -1142,9 +1196,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz", - "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", + "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", "cpu": [ "mips64el" ], @@ -1158,9 +1212,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz", - "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", + "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", "cpu": [ "ppc64" ], @@ -1174,9 +1228,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz", - "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", + "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", "cpu": [ "riscv64" ], @@ -1190,9 +1244,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz", - "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", + "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", "cpu": [ "s390x" ], @@ -1206,9 +1260,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", - "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", + "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", "cpu": [ "x64" ], @@ -1221,10 +1275,26 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", + "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz", - "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", + "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", "cpu": [ "x64" ], @@ -1238,9 +1308,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz", - "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", + "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", "cpu": [ "arm64" ], @@ -1254,9 +1324,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz", - "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", + "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", "cpu": [ "x64" ], @@ -1270,9 +1340,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz", - "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", + "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", "cpu": [ "x64" ], @@ -1286,9 +1356,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz", - "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", + "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", "cpu": [ "arm64" ], @@ -1302,9 +1372,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz", - "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", + "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", "cpu": [ "ia32" ], @@ -1318,9 +1388,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz", - "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", + "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", "cpu": [ "x64" ], @@ -1349,24 +1419,81 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.0.tgz", + "integrity": "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -1374,7 +1501,7 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1396,12 +1523,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1413,32 +1534,17 @@ } }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -1457,69 +1563,78 @@ "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "node_modules/@eslint/js": { + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", + "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", "dev": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", + "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.12.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.6.0.tgz", - "integrity": "sha512-60G28ke/sXdtS9KZCpZSHHkCbdsOGEhIUGlwq6yhY74UpTiToIh8np7A8yphhM4BWsvNFtIvLpi4co+h9Mr9Ow==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", "engines": { "node": ">=6" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, "engines": { - "node": ">=10.10.0" + "node": ">=18.18.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" }, "engines": { - "node": "*" + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@humanwhocodes/module-importer": { @@ -1535,254 +1650,346 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "dev": true + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@iharbeck/ngx-virtual-scroller": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/@iharbeck/ngx-virtual-scroller/-/ngx-virtual-scroller-17.0.2.tgz", - "integrity": "sha512-ixB4NZ4kwboYC/prWNkO7MohTx9pEilcBMQOmSxBn92P4GGbu5+HjiCDe+v5fj31bGIDiya2xXWouWkcuG/Y+w==", + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/@iharbeck/ngx-virtual-scroller/-/ngx-virtual-scroller-19.0.1.tgz", + "integrity": "sha512-dtn4CpbEY92H9nd1A48WNhsyUgtFBjC83xcsc9VzlSQT/KN2fEx0oBs0Obnn6ZdPanDP/IQdlBgmANmlds/wHA==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@tweenjs/tween.js": "^23.1.1" + "@tweenjs/tween.js": "^25.0.0" } }, "node_modules/@inquirer/checkbox": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.5.0.tgz", - "integrity": "sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.4.tgz", + "integrity": "sha512-d30576EZdApjAMceijXA5jDzRQHT/MygbC+J8I7EqA6f/FRpYxlRtRJbHF8gHeWYeSdOuTEJqonn7QLB1ELezA==", "dev": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.3", + "@inquirer/core": "^10.1.9", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/confirm": { - "version": "3.1.22", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.1.22.tgz", - "integrity": "sha512-gsAKIOWBm2Q87CDfs9fEo7wJT3fwWIJfnDGMn9Qy74gBnNFOACDNfhUzovubbJjWnKLGBln7/NcSmZwj5DuEXg==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", + "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", "dev": true, "dependencies": { - "@inquirer/core": "^9.0.10", - "@inquirer/type": "^1.5.2" + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/core": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz", - "integrity": "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==", + "version": "10.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.9.tgz", + "integrity": "sha512-sXhVB8n20NYkUBfDYgizGHlpRVaCRjtuzNZA6xpALIUbkgfd2Hjz+DfEN6+h1BRnuxw0/P4jCIMjMsEOAMwAJw==", "dev": true, "dependencies": { - "@inquirer/figures": "^1.0.6", - "@inquirer/type": "^2.0.0", - "@types/mute-stream": "^0.0.4", - "@types/node": "^22.5.5", - "@types/wrap-ansi": "^3.0.0", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", - "mute-stream": "^1.0.0", + "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", - "strip-ansi": "^6.0.1", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, "engines": { "node": ">=18" - } - }, - "node_modules/@inquirer/core/node_modules/@inquirer/type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz", - "integrity": "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==", - "dev": true, - "dependencies": { - "mute-stream": "^1.0.0" }, - "engines": { - "node": ">=18" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/editor": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-2.2.0.tgz", - "integrity": "sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==", + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.9.tgz", + "integrity": "sha512-8HjOppAxO7O4wV1ETUlJFg6NDjp/W2NP5FB9ZPAcinAlNT4ZIWOLe2pUVwmmPRSV0NMdI5r/+lflN55AwZOKSw==", "dev": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3", + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", "external-editor": "^3.1.0" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/expand": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-2.3.0.tgz", - "integrity": "sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw==", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.11.tgz", + "integrity": "sha512-OZSUW4hFMW2TYvX/Sv+NnOZgO8CHT2TU1roUCUIF2T+wfw60XFRRp9MRUPCT06cRnKL+aemt2YmTWwt7rOrNEA==", "dev": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3", + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", "yoctocolors-cjs": "^2.1.2" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/figures": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.7.tgz", - "integrity": "sha512-m+Trk77mp54Zma6xLkLuY+mvanPxlE4A7yNKs2HBiyZ4UkVs28Mv5c/pgWrHeInx+USHeX/WEPzjrWrcJiQgjw==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", + "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", "dev": true, "engines": { "node": ">=18" } }, "node_modules/@inquirer/input": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.3.0.tgz", - "integrity": "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.8.tgz", + "integrity": "sha512-WXJI16oOZ3/LiENCAxe8joniNp8MQxF6Wi5V+EBbVA0ZIOpFcL4I9e7f7cXse0HJeIPCWO8Lcgnk98juItCi7Q==", "dev": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3" + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/number": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-1.1.0.tgz", - "integrity": "sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.11.tgz", + "integrity": "sha512-pQK68CsKOgwvU2eA53AG/4npRTH2pvs/pZ2bFvzpBhrznh8Mcwt19c+nMO7LHRr3Vreu1KPhNBF3vQAKrjIulw==", "dev": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3" + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/password": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-2.2.0.tgz", - "integrity": "sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.11.tgz", + "integrity": "sha512-dH6zLdv+HEv1nBs96Case6eppkRggMe8LoOTl30+Gq5Wf27AO/vHFgStTVz4aoevLdNXqwE23++IXGw4eiOXTg==", "dev": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3", + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", "ansi-escapes": "^4.3.2" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/prompts": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-5.3.8.tgz", - "integrity": "sha512-b2BudQY/Si4Y2a0PdZZL6BeJtl8llgeZa7U2j47aaJSCeAl1e4UI7y8a9bSkO3o/ZbZrgT5muy/34JbsjfIWxA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", "dev": true, "dependencies": { - "@inquirer/checkbox": "^2.4.7", - "@inquirer/confirm": "^3.1.22", - "@inquirer/editor": "^2.1.22", - "@inquirer/expand": "^2.1.22", - "@inquirer/input": "^2.2.9", - "@inquirer/number": "^1.0.10", - "@inquirer/password": "^2.1.22", - "@inquirer/rawlist": "^2.2.4", - "@inquirer/search": "^1.0.7", - "@inquirer/select": "^2.4.7" + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/rawlist": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-2.3.0.tgz", - "integrity": "sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.11.tgz", + "integrity": "sha512-uAYtTx0IF/PqUAvsRrF3xvnxJV516wmR6YVONOmCWJbbt87HcDHLfL9wmBQFbNJRv5kCjdYKrZcavDkH3sVJPg==", "dev": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3", + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", "yoctocolors-cjs": "^2.1.2" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/search": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-1.1.0.tgz", - "integrity": "sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.11.tgz", + "integrity": "sha512-9CWQT0ikYcg6Ls3TOa7jljsD7PgjcsYEM0bYE+Gkz+uoW9u8eaJCRHJKkucpRE5+xKtaaDbrND+nPDoxzjYyew==", "dev": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.3", + "@inquirer/core": "^10.1.9", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", "yoctocolors-cjs": "^2.1.2" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/select": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.5.0.tgz", - "integrity": "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.1.0.tgz", + "integrity": "sha512-z0a2fmgTSRN+YBuiK1ROfJ2Nvrpij5lVN3gPDkQGhavdvIVGHGW29LwYZfM/j42Ai2hUghTI/uoBuTbrJk42bA==", "dev": true, "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.3", + "@inquirer/core": "^10.1.9", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/type": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", - "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.5.tgz", + "integrity": "sha512-ZJpeIYYueOz/i/ONzrfof8g89kNdO2hjGuvULROo3O8rlB2CRtSseE5KeirnyE4t/thAn/EwvS/vuQeJCn+NZg==", "dev": true, - "dependencies": { - "mute-stream": "^1.0.0" - }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@iplab/ngx-file-upload": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/@iplab/ngx-file-upload/-/ngx-file-upload-18.0.0.tgz", - "integrity": "sha512-Uz+011ZOGtVeFAPuOcFHBB/hyLZrV3RNOqT21J13YMqWr5jadba0t67towrQ7VTHLMYt1Du/UHDmv5wV/h7/sg==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@iplab/ngx-file-upload/-/ngx-file-upload-19.0.3.tgz", + "integrity": "sha512-PXQroFbMrQwg69b/j6Im9R8DkLz15YxiA0ATlFpOTPRtDhAWQMIRNdxbcqRLmBLdPvrsXpH/gN30f0GyC1k/fw==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/animations": "^18.0.0", - "@angular/common": "^18.0.0", - "@angular/core": "^18.0.0", - "@angular/forms": "^18.0.0", + "@angular/animations": "^19.0.0", + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0", "rxjs": "^7.0.0" } }, @@ -1875,6 +2082,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -1928,12 +2147,11 @@ } }, "node_modules/@jsverse/transloco": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@jsverse/transloco/-/transloco-7.5.0.tgz", - "integrity": "sha512-OnK8y84TWxWeNH+Qw0kHPECpmLOrwMRP+NMcUXm3lEqRrd13qe9XkhbAysAHGJ6kfZqgIiAMS9rqnFf6JTSc5g==", + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@jsverse/transloco/-/transloco-7.6.1.tgz", + "integrity": "sha512-hFFKJ1pVFYeW2E4UFETQpOeOn0tuncCSUdRewbq3LiV+qS9x4Z2XVuCaAaFPdiNhy4nUKHWFX1pWjpZ5XjUPaQ==", "dependencies": { "@jsverse/transloco-utils": "^7.0.0", - "flat": "6.0.1", "fs-extra": "^11.0.0", "glob": "^10.0.0", "lodash.kebabcase": "^4.1.1", @@ -2007,24 +2225,45 @@ } }, "node_modules/@listr2/prompt-adapter-inquirer": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.15.tgz", - "integrity": "sha512-MZrGem/Ujjd4cPTLYDfCZK2iKKeiO/8OX13S6jqxldLs0Prf2aGqVlJ77nMBqMv7fzqgXEgjrNHLXcKR8l9lOg==", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.18.tgz", + "integrity": "sha512-0hz44rAcrphyXcA8IS7EJ2SCoaBZD2u5goE8S/e+q/DL+dOGpqpcLidVOFeLG3VgML62SXmfRLAhWt0zL1oW4Q==", "dev": true, "dependencies": { - "@inquirer/type": "^1.5.1" + "@inquirer/type": "^1.5.5" }, "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "@inquirer/prompts": ">= 3 < 6" + "@inquirer/prompts": ">= 3 < 8" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer/node_modules/@inquirer/type": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", + "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", + "dev": true, + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer/node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.0.13.tgz", - "integrity": "sha512-uiKPB0Fv6WEEOZjruu9a6wnW/8jrjzlZbxXscMB8kuCJ1k6kHpcBnuvaAWcqhbI7rqX5GKziwWEdD+wi2gNLfA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.6.tgz", + "integrity": "sha512-yF/ih9EJJZc72psFQbwnn8mExIWfTnzWJg+N02hnpXtDPETYLmQswIMBn7+V88lfCaFrMozJsUvcEQIkEPU0Gg==", "cpu": [ "arm64" ], @@ -2035,9 +2274,9 @@ ] }, "node_modules/@lmdb/lmdb-darwin-x64": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.0.13.tgz", - "integrity": "sha512-bEVIIfK5mSQoG1R19qA+fJOvCB+0wVGGnXHT3smchBVahYBdlPn2OsZZKzlHWfb1E+PhLBmYfqB5zQXFP7hJig==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.2.6.tgz", + "integrity": "sha512-5BbCumsFLbCi586Bb1lTWQFkekdQUw8/t8cy++Uq251cl3hbDIGEwD9HAwh8H6IS2F6QA9KdKmO136LmipRNkg==", "cpu": [ "x64" ], @@ -2048,9 +2287,9 @@ ] }, "node_modules/@lmdb/lmdb-linux-arm": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.0.13.tgz", - "integrity": "sha512-Yml1KlMzOnXj/tnW7yX8U78iAzTk39aILYvCPbqeewAq1kSzl+w59k/fiVkTBfvDi/oW/5YRxL+Fq+Y1Fr1r2Q==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.2.6.tgz", + "integrity": "sha512-+6XgLpMb7HBoWxXj+bLbiiB4s0mRRcDPElnRS3LpWRzdYSe+gFk5MT/4RrVNqd2MESUDmb53NUXw1+BP69bjiQ==", "cpu": [ "arm" ], @@ -2061,9 +2300,9 @@ ] }, "node_modules/@lmdb/lmdb-linux-arm64": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.0.13.tgz", - "integrity": "sha512-afbVrsMgZ9dUTNUchFpj5VkmJRxvht/u335jUJ7o23YTbNbnpmXif3VKQGCtnjSh+CZaqm6N3CPG8KO3zwyZ1Q==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.2.6.tgz", + "integrity": "sha512-l5VmJamJ3nyMmeD1ANBQCQqy7do1ESaJQfKPSm2IG9/ADZryptTyCj8N6QaYgIWewqNUrcbdMkJajRQAt5Qjfg==", "cpu": [ "arm64" ], @@ -2074,9 +2313,9 @@ ] }, "node_modules/@lmdb/lmdb-linux-x64": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.0.13.tgz", - "integrity": "sha512-vOtxu0xC0SLdQ2WRXg8Qgd8T32ak4SPqk5zjItRszrJk2BdeXqfGxBJbP7o4aOvSPSmSSv46Lr1EP4HXU8v7Kg==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.2.6.tgz", + "integrity": "sha512-nDYT8qN9si5+onHYYaI4DiauDMx24OAiuZAUsEqrDy+ja/3EbpXPX/VAkMV8AEaQhy3xc4dRC+KcYIvOFefJ4Q==", "cpu": [ "x64" ], @@ -2087,9 +2326,9 @@ ] }, "node_modules/@lmdb/lmdb-win32-x64": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.0.13.tgz", - "integrity": "sha512-UCrMJQY/gJnOl3XgbWRZZUvGGBuKy6i0YNSptgMzHBjs+QYDYR1Mt/RLTOPy4fzzves65O1EDmlL//OzEqoLlA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.2.6.tgz", + "integrity": "sha512-XlqVtILonQnG+9fH2N3Aytria7P/1fwDgDhl29rde96uH2sLB8CHORIf2PfuLVzFQJ7Uqp8py9AYwr3ZUCFfWg==", "cpu": [ "x64" ], @@ -2189,18 +2428,306 @@ "win32" ] }, + "node_modules/@napi-rs/nice": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", + "integrity": "sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.0.1", + "@napi-rs/nice-android-arm64": "1.0.1", + "@napi-rs/nice-darwin-arm64": "1.0.1", + "@napi-rs/nice-darwin-x64": "1.0.1", + "@napi-rs/nice-freebsd-x64": "1.0.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.0.1", + "@napi-rs/nice-linux-arm64-gnu": "1.0.1", + "@napi-rs/nice-linux-arm64-musl": "1.0.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.0.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.0.1", + "@napi-rs/nice-linux-s390x-gnu": "1.0.1", + "@napi-rs/nice-linux-x64-gnu": "1.0.1", + "@napi-rs/nice-linux-x64-musl": "1.0.1", + "@napi-rs/nice-win32-arm64-msvc": "1.0.1", + "@napi-rs/nice-win32-ia32-msvc": "1.0.1", + "@napi-rs/nice-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz", + "integrity": "sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz", + "integrity": "sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz", + "integrity": "sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz", + "integrity": "sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz", + "integrity": "sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@ng-bootstrap/ng-bootstrap": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-17.0.1.tgz", - "integrity": "sha512-utbm8OXIoqVVYGVzQkOS773ymbjc+UMkXv8lyi7hTqLhCQs0rZ0yA74peqVZRuOGXLHgcSTA7fnJhA80iQOblw==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-18.0.0.tgz", + "integrity": "sha512-GeSAz4yiGq49psdte8kcf+Y562wB3jK/qKRAkh6iA32lcXmy2sfQXVAmlHdjZ3AyP+E8lf3yMwuPdSKiYcDgSg==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": "^18.0.0", - "@angular/core": "^18.0.0", - "@angular/forms": "^18.0.0", - "@angular/localize": "^18.0.0", + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0", + "@angular/localize": "^19.0.0", "@popperjs/core": "^2.11.8", "rxjs": "^6.5.3 || ^7.4.0" } @@ -2238,9 +2765,9 @@ } }, "node_modules/@npmcli/agent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", - "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", "dev": true, "dependencies": { "agent-base": "^7.1.0", @@ -2250,7 +2777,7 @@ "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { @@ -2260,35 +2787,34 @@ "dev": true }, "node_modules/@npmcli/fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", - "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", "dev": true, "dependencies": { "semver": "^7.3.5" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/git": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.8.tgz", - "integrity": "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", + "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", "dev": true, "dependencies": { - "@npmcli/promise-spawn": "^7.0.0", - "ini": "^4.1.3", + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", "lru-cache": "^10.0.1", - "npm-pick-manifest": "^9.0.0", - "proc-log": "^4.0.0", - "promise-inflight": "^1.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "semver": "^7.3.5", - "which": "^4.0.0" + "which": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/git/node_modules/isexe": { @@ -2307,9 +2833,9 @@ "dev": true }, "node_modules/@npmcli/git/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -2318,62 +2844,62 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/installed-package-contents": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz", - "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", + "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", "dev": true, "dependencies": { - "npm-bundled": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" }, "bin": { "installed-package-contents": "bin/index.js" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/node-gyp": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", - "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", + "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/package-json": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.1.tgz", - "integrity": "sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", + "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", "dev": true, "dependencies": { - "@npmcli/git": "^5.0.0", + "@npmcli/git": "^6.0.0", "glob": "^10.2.2", - "hosted-git-info": "^7.0.0", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^6.0.0", - "proc-log": "^4.0.0", - "semver": "^7.5.3" + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/promise-spawn": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz", - "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", + "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", "dev": true, "dependencies": { - "which": "^4.0.0" + "which": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/promise-spawn/node_modules/isexe": { @@ -2386,9 +2912,9 @@ } }, "node_modules/@npmcli/promise-spawn/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -2397,33 +2923,33 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/redact": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-2.0.1.tgz", - "integrity": "sha512-YgsR5jCQZhVmTJvjduTOIHph0L73pK8xwMVaDY0PatySqVM9AZj93jpoXYSJqfHFxFkN9dmqTw6OiqExsS3LPw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.1.1.tgz", + "integrity": "sha512-3Hc2KGIkrvJWJqTbvueXzBeZlmvoOxc2jyX00yzr3+sNFquJg0N8hH4SAPLPVrkWIRQICVpVgjrss971awXVnA==", "dev": true, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/run-script": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-8.1.0.tgz", - "integrity": "sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", + "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", "dev": true, "dependencies": { - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/package-json": "^5.0.0", - "@npmcli/promise-spawn": "^7.0.0", - "node-gyp": "^10.0.0", - "proc-log": "^4.0.0", - "which": "^4.0.0" + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/run-script/node_modules/isexe": { @@ -2436,9 +2962,9 @@ } }, "node_modules/@npmcli/run-script/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -2447,9 +2973,325 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "optional": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2459,21 +3301,6 @@ "node": ">=14" } }, - "node_modules/@playwright/test": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0.tgz", - "integrity": "sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==", - "dev": true, - "dependencies": { - "playwright": "1.49.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.25", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", @@ -2490,9 +3317,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", - "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", + "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", "cpu": [ "arm" ], @@ -2503,9 +3330,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", - "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", + "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", "cpu": [ "arm64" ], @@ -2516,9 +3343,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", - "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", + "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", "cpu": [ "arm64" ], @@ -2529,9 +3356,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", - "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", + "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", "cpu": [ "x64" ], @@ -2541,10 +3368,36 @@ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", + "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", + "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", - "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", + "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", "cpu": [ "arm" ], @@ -2555,9 +3408,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", - "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", + "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", "cpu": [ "arm" ], @@ -2568,9 +3421,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", - "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", + "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", "cpu": [ "arm64" ], @@ -2581,9 +3434,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", - "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", + "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", "cpu": [ "arm64" ], @@ -2593,10 +3446,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", + "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", - "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", + "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", "cpu": [ "ppc64" ], @@ -2607,9 +3473,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", - "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", + "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", "cpu": [ "riscv64" ], @@ -2620,9 +3486,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", - "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", + "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", "cpu": [ "s390x" ], @@ -2633,9 +3499,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", - "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", + "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", "cpu": [ "x64" ], @@ -2646,9 +3512,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", - "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", + "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", "cpu": [ "x64" ], @@ -2659,9 +3525,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", - "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", + "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", "cpu": [ "arm64" ], @@ -2672,9 +3538,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", - "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", + "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", "cpu": [ "ia32" ], @@ -2685,9 +3551,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", - "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", + "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", "cpu": [ "x64" ], @@ -2698,13 +3564,13 @@ ] }, "node_modules/@schematics/angular": { - "version": "18.2.10", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.10.tgz", - "integrity": "sha512-2pDHT4aSzfs8Up4RQmHHuFd5FeuUebS1ZJwyt46MfXzRMFtzUZV/JKsIvDqyMwnkvFfLvgJyTCkl8JGw5jQObg==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.6.tgz", + "integrity": "sha512-fmbF9ONmEZqxHocCwOSWG2mHp4a22d1uW+DZUBUgZSBUFIrnFw42deOxDq8mkZOZ1Tc73UpLN2GKI7iJeUqS2A==", "dev": true, "dependencies": { - "@angular-devkit/core": "18.2.10", - "@angular-devkit/schematics": "18.2.10", + "@angular-devkit/core": "19.2.6", + "@angular-devkit/schematics": "19.2.6", "jsonc-parser": "3.3.1" }, "engines": { @@ -2713,110 +3579,124 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@siemens/ngx-datatable": { + "version": "22.4.1", + "resolved": "https://registry.npmjs.org/@siemens/ngx-datatable/-/ngx-datatable-22.4.1.tgz", + "integrity": "sha512-Z19zaxu7tpwMHWc1h5Om9/sZJ39MWTQypju6T6WH7QIkelKgZE7DbYk3siD41vkR/62vT+q0Z1voC2OyxgRX9g==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=17.0.0", + "@angular/core": ">=17.0.0", + "@angular/platform-browser": ">=17.0.0", + "rxjs": "^7.8.0" + } + }, "node_modules/@sigstore/bundle": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz", - "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", + "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2" + "@sigstore/protobuf-specs": "^0.4.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz", - "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", + "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", "dev": true, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz", - "integrity": "sha512-c6B0ehIWxMI8wiS/bj6rHMPqeFvngFV7cDU/MY+B16P9Z3Mp9k8L93eYZ7BYzSickzuqAQqAq0V956b3Ju6mLw==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.0.tgz", + "integrity": "sha512-o09cLSIq9EKyRXwryWDOJagkml9XgQCoCSRjHOnHLnvsivaW7Qznzz6yjfV7PHJHhIvyp8OH7OX8w0Dc5bQK7A==", "dev": true, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/sign": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz", - "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", + "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.3.2", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "make-fetch-happen": "^13.0.1", - "proc-log": "^4.2.0", + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/tuf": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz", - "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.0.tgz", + "integrity": "sha512-suVMQEA+sKdOz5hwP9qNcEjX6B45R+hFFr4LAWzbRc5O+U2IInwvay/bpG5a4s+qR35P/JK/PiKiRGjfuLy1IA==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2", - "tuf-js": "^2.2.1" + "@sigstore/protobuf-specs": "^0.4.0", + "tuf-js": "^3.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/verify": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz", - "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.0.tgz", + "integrity": "sha512-kAAM06ca4CzhvjIZdONAL9+MLppW3K48wOFy1TbuaWFW/OMfl8JuTgW0Bm02JB1WJGT/ET2eqav0KTEKmxqkIA==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.3.2", - "@sigstore/core": "^1.1.0", - "@sigstore/protobuf-specs": "^0.3.2" + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@swimlane/ngx-charts": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-20.5.0.tgz", - "integrity": "sha512-PNBIHdu/R3ceD7jnw1uCBVOj4k3T6IxfdW6xsDsglGkZyoWMEEq4tLoEurjLEKzmDtRv9c35kVNOXy0lkOuXeA==", + "version": "22.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-22.0.0-alpha.0.tgz", + "integrity": "sha512-sauI4QcfpuKXmRWajpeVtAoT7z8uI3u1+hvfcsJ796LRr06C676dkjoZsk7aX3EU+6uF8mJpXClOT/JcfnZrEA==", "dependencies": { - "d3-array": "^3.1.1", + "d3-array": "^3.2.0", "d3-brush": "^3.0.0", "d3-color": "^3.1.0", "d3-ease": "^3.0.1", "d3-format": "^3.1.0", - "d3-hierarchy": "^3.1.0", + "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", - "d3-time-format": "^3.0.0", + "d3-time-format": "^4.1.0", "d3-transition": "^3.0.1", - "rfdc": "^1.3.0", - "tslib": "^2.0.0" + "gradient-path": "^2.3.0", + "tslib": "^2.3.1" }, "peerDependencies": { - "@angular/animations": ">=12.0.0", - "@angular/cdk": ">=12.0.0", - "@angular/common": ">=12.0.0", - "@angular/core": ">=12.0.0", - "@angular/forms": ">=12.0.0", - "@angular/platform-browser": ">=12.0.0", - "@angular/platform-browser-dynamic": ">=12.0.0", - "rxjs": "^6.5.3 || ^7.4.0" + "@angular/animations": "17.x || 18.x || 19.x", + "@angular/cdk": "17.x || 18.x || 19.x", + "@angular/common": "17.x || 18.x || 19.x", + "@angular/core": "17.x || 18.x || 19.x", + "@angular/forms": "17.x || 18.x || 19.x", + "@angular/platform-browser": "17.x || 18.x || 19.x", + "@angular/platform-browser-dynamic": "17.x || 18.x || 19.x", + "rxjs": "7.x" } }, "node_modules/@tsconfig/node10": { @@ -2853,22 +3733,22 @@ } }, "node_modules/@tufjs/models": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz", - "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", + "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", "dev": true, "dependencies": { "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.4" + "minimatch": "^9.0.5" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@tweenjs/tween.js": { - "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==" + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3161,9 +4041,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, "node_modules/@types/file-saver": { @@ -3178,51 +4058,47 @@ "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", "dev": true }, - "node_modules/@types/luxon": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", - "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, - "node_modules/@types/mute-stream": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", - "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", - "dev": true, - "dependencies": { - "@types/node": "*" - } + "node_modules/@types/luxon": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", + "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", + "dev": true }, "node_modules/@types/node": { - "version": "22.8.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.0.tgz", - "integrity": "sha512-84rafSBHC/z1i1E3p0cJwKA+CfYDNSXX9WSZBRopjIzLET8oNt6ht2tei4C7izwDeEiLLfdeSVBv1egOH916hg==", + "version": "22.13.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz", + "integrity": "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==", "dev": true, "dependencies": { - "undici-types": "~6.19.8" + "undici-types": "~6.20.0" } }, - "node_modules/@types/wrap-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", - "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", - "dev": true + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", - "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.28.0.tgz", + "integrity": "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.11.0", - "@typescript-eslint/type-utils": "8.11.0", - "@typescript-eslint/utils": "8.11.0", - "@typescript-eslint/visitor-keys": "8.11.0", + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/type-utils": "8.28.0", + "@typescript-eslint/utils": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3233,24 +4109,20 @@ }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", - "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.28.0.tgz", + "integrity": "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.11.0", - "@typescript-eslint/types": "8.11.0", - "@typescript-eslint/typescript-estree": "8.11.0", - "@typescript-eslint/visitor-keys": "8.11.0", + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/typescript-estree": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4" }, "engines": { @@ -3261,22 +4133,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", - "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.28.0.tgz", + "integrity": "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.11.0", - "@typescript-eslint/visitor-keys": "8.11.0" + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3287,15 +4155,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", - "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.28.0.tgz", + "integrity": "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.11.0", - "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/typescript-estree": "8.28.0", + "@typescript-eslint/utils": "8.28.0", "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3304,16 +4172,15 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", - "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.28.0.tgz", + "integrity": "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3324,19 +4191,19 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", - "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.28.0.tgz", + "integrity": "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.11.0", - "@typescript-eslint/visitor-keys": "8.11.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3345,22 +4212,20 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", - "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.28.0.tgz", + "integrity": "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.11.0", - "@typescript-eslint/types": "8.11.0", - "@typescript-eslint/typescript-estree": "8.11.0" + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/typescript-estree": "8.28.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3370,17 +4235,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", - "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.28.0.tgz", + "integrity": "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.11.0", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.28.0", + "eslint-visitor-keys": "^4.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3390,22 +4256,28 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "node_modules/@vitejs/plugin-basic-ssl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz", - "integrity": "sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, "engines": { - "node": ">=14.6.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz", + "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", + "dev": true, + "engines": { + "node": ">=14.21.3" }, "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" } }, "node_modules/@yarnpkg/lockfile": { @@ -3415,12 +4287,12 @@ "dev": true }, "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", + "integrity": "sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/abort-controller": { @@ -3435,9 +4307,9 @@ } }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -3465,30 +4337,14 @@ } }, "node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -3505,6 +4361,23 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3542,37 +4415,17 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -3615,16 +4468,23 @@ } ] }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/beasties": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.2.0.tgz", + "integrity": "sha512-Ljqskqx/tbZagIglYoJIMzH5zgssyp+in9+9sAyh15N22AornBeIDnb8EZ6Rk+6ShfMxd92uO3gfpT0NtZbpow==", "dev": true, - "engines": { - "node": ">=8" + "dependencies": { + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^9.1.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=14.0.0" } }, "node_modules/bl": { @@ -3734,13 +4594,19 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "node_modules/cacache": { - "version": "18.0.4", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", - "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", "dev": true, "dependencies": { - "@npmcli/fs": "^3.1.0", + "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", @@ -3748,13 +4614,22 @@ "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "engines": { + "node": ">=18" } }, "node_modules/cacache/node_modules/lru-cache": { @@ -3763,6 +4638,47 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/cacache/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3772,9 +4688,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001669", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", - "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", + "version": "1.0.30001707", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", + "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==", "funding": [ { "type": "opencollective", @@ -3816,30 +4732,6 @@ "resolved": "https://registry.npmjs.org/charts.css/-/charts.css-1.1.0.tgz", "integrity": "sha512-K1Qyb8ZKsu5cDrVbZeHECk/xSq6iOl8IDTR35uaMdhr/Vyyxvg9nYQy3KNB3aidxJ2E251afX5q2725N0uL3Vw==" }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -3849,15 +4741,6 @@ "node": ">=10" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -4023,7 +4906,8 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -4050,47 +4934,16 @@ } } }, - "node_modules/cosmiconfig/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, - "node_modules/critters": { - "version": "0.0.24", - "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.24.tgz", - "integrity": "sha512-Oyqew0FGM0wYUSNqR0L6AteO5MpMoUU0rhKRieXeiKs+PmRTxiJMyaunYB2KF6fQ3dzChXKCpbFOEJx3OQ1v/Q==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "css-select": "^5.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.2", - "htmlparser2": "^8.0.2", - "postcss": "^8.4.23", - "postcss-media-query-parser": "^0.2.3" - } - }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4306,34 +5159,16 @@ } }, "node_modules/d3-time-format": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", - "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "dependencies": { - "d3-time": "1 - 2" + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" } }, - "node_modules/d3-time-format/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-time-format/node_modules/d3-time": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", - "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", - "dependencies": { - "d3-array": "2" - } - }, - "node_modules/d3-time-format/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" - }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -4409,6 +5244,7 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", "dev": true, + "optional": true, "engines": { "node": ">=8" } @@ -4430,18 +5266,6 @@ "node": ">=0.3.1" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -4492,9 +5316,9 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, "dependencies": { "dom-serializer": "^2.0.0", @@ -4530,6 +5354,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -4539,6 +5364,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -4551,7 +5377,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "devOptional": true, "engines": { "node": ">=0.12" }, @@ -4595,9 +5420,9 @@ } }, "node_modules/esbuild": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz", - "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", + "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", "dev": true, "hasInstallScript": true, "bin": { @@ -4607,30 +5432,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.0", - "@esbuild/android-arm": "0.23.0", - "@esbuild/android-arm64": "0.23.0", - "@esbuild/android-x64": "0.23.0", - "@esbuild/darwin-arm64": "0.23.0", - "@esbuild/darwin-x64": "0.23.0", - "@esbuild/freebsd-arm64": "0.23.0", - "@esbuild/freebsd-x64": "0.23.0", - "@esbuild/linux-arm": "0.23.0", - "@esbuild/linux-arm64": "0.23.0", - "@esbuild/linux-ia32": "0.23.0", - "@esbuild/linux-loong64": "0.23.0", - "@esbuild/linux-mips64el": "0.23.0", - "@esbuild/linux-ppc64": "0.23.0", - "@esbuild/linux-riscv64": "0.23.0", - "@esbuild/linux-s390x": "0.23.0", - "@esbuild/linux-x64": "0.23.0", - "@esbuild/netbsd-x64": "0.23.0", - "@esbuild/openbsd-arm64": "0.23.0", - "@esbuild/openbsd-x64": "0.23.0", - "@esbuild/sunos-x64": "0.23.0", - "@esbuild/win32-arm64": "0.23.0", - "@esbuild/win32-ia32": "0.23.0", - "@esbuild/win32-x64": "0.23.0" + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" } }, "node_modules/escalade": { @@ -4642,64 +5468,69 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", + "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.2", + "@eslint/config-helpers": "^0.2.0", + "@eslint/core": "^0.12.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.23.0", + "@eslint/plugin-kit": "^0.2.7", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-scope": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", - "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", @@ -4740,12 +5571,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -4768,17 +5593,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4812,33 +5633,6 @@ "node": ">=10.13.0" } }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4902,30 +5696,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", "dev": true, "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4981,6 +5775,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true + }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -4990,9 +5790,9 @@ } }, "node_modules/exponential-backoff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", - "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", + "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", "dev": true }, "node_modules/external-editor": { @@ -5009,18 +5809,6 @@ "node": ">=4" } }, - "node_modules/external-editor/node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5028,15 +5816,15 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -5078,15 +5866,15 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/file-saver": { @@ -5105,35 +5893,23 @@ "node": ">=8" } }, - "node_modules/flat": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/flat/-/flat-6.0.1.tgz", - "integrity": "sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw==", - "bin": { - "flat": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, "node_modules/foreground-child": { @@ -5283,6 +6059,14 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/gradient-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/gradient-path/-/gradient-path-2.3.0.tgz", + "integrity": "sha512-vZdF/Z0EpqUztzWXFjFC16lqcialHacYoRonslk/bC6CuujkuIrqx7etlzdYHY4SnUU94LRWESamZKfkGh7yYQ==", + "dependencies": { + "tinygradient": "^1.0.0" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -5325,15 +6109,15 @@ } }, "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.2.tgz", + "integrity": "sha512-sYKnA7eGln5ov8T8gnYlkSOxFJvywzEx9BueN6xo/GKO8PGiI6uK6xx+DIGe45T3bdVjLAQDQW1aicT8z8JwQg==", "dev": true, "dependencies": { "lru-cache": "^10.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/hosted-git-info/node_modules/lru-cache": { @@ -5349,9 +6133,9 @@ "dev": true }, "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -5363,8 +6147,8 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" + "domutils": "^3.1.0", + "entities": "^4.5.0" } }, "node_modules/http-cache-semantics": { @@ -5387,12 +6171,12 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -5440,21 +6224,21 @@ } }, "node_modules/ignore-walk": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.5.tgz", - "integrity": "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", + "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", "dev": true, "dependencies": { "minimatch": "^9.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/immutable": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", - "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", "dev": true }, "node_modules/import-fresh": { @@ -5489,15 +6273,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5513,12 +6288,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", - "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/internmap": { @@ -5542,36 +6317,21 @@ "node": ">= 12" } }, - "node_modules/ip-address/node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "dependencies": { - "binary-extensions": "^2.0.0" + "hasown": "^2.0.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5612,12 +6372,6 @@ "node": ">=8" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5626,15 +6380,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -5757,6 +6502,17 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsbn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", @@ -5764,14 +6520,14 @@ "dev": true }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-buffer": { @@ -5781,12 +6537,12 @@ "dev": true }, "node_modules/json-parse-even-better-errors": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", - "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/json-schema-traverse": { @@ -5910,9 +6666,9 @@ } }, "node_modules/listr2": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz", - "integrity": "sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==", + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", + "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", "dev": true, "dependencies": { "cli-truncate": "^4.0.0", @@ -5956,12 +6712,6 @@ "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "dev": true }, - "node_modules/listr2/node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true - }, "node_modules/listr2/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -6012,28 +6762,29 @@ } }, "node_modules/lmdb": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.0.13.tgz", - "integrity": "sha512-UGe+BbaSUQtAMZobTb4nHvFMrmvuAQKSeaqAX2meTEQjfsbpl5sxdHD8T72OnwD4GU9uwNhYXIVe4QGs8N9Zyw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.2.6.tgz", + "integrity": "sha512-SuHqzPl7mYStna8WRotY8XX/EUZBjjv3QyKIByeCLFfC9uXT/OIHByEcA07PzbMfQAM0KYJtLgtpMRlIe5dErQ==", "dev": true, "hasInstallScript": true, + "optional": true, "dependencies": { - "msgpackr": "^1.10.2", + "msgpackr": "^1.11.2", "node-addon-api": "^6.1.0", "node-gyp-build-optional-packages": "5.2.2", - "ordered-binary": "^1.4.1", + "ordered-binary": "^1.5.3", "weak-lru-cache": "^1.2.2" }, "bin": { "download-lmdb-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@lmdb/lmdb-darwin-arm64": "3.0.13", - "@lmdb/lmdb-darwin-x64": "3.0.13", - "@lmdb/lmdb-linux-arm": "3.0.13", - "@lmdb/lmdb-linux-arm64": "3.0.13", - "@lmdb/lmdb-linux-x64": "3.0.13", - "@lmdb/lmdb-win32-x64": "3.0.13" + "@lmdb/lmdb-darwin-arm64": "3.2.6", + "@lmdb/lmdb-darwin-x64": "3.2.6", + "@lmdb/lmdb-linux-arm": "3.2.6", + "@lmdb/lmdb-linux-arm64": "3.2.6", + "@lmdb/lmdb-linux-x64": "3.2.6", + "@lmdb/lmdb-win32-x64": "3.2.6" } }, "node_modules/lodash.kebabcase": { @@ -6261,17 +7012,17 @@ } }, "node_modules/luxon": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", - "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", "engines": { "node": ">=12" } }, "node_modules/magic-string": { - "version": "0.30.11", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", - "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" @@ -6299,26 +7050,25 @@ "dev": true }, "node_modules/make-fetch-happen": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", - "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", "dev": true, "dependencies": { - "@npmcli/agent": "^2.0.0", - "cacache": "^18.0.0", + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", - "is-lambda": "^1.0.1", "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", + "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "proc-log": "^4.2.0", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", - "ssri": "^10.0.0" + "ssri": "^12.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/merge2": { @@ -6373,9 +7123,9 @@ } }, "node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -6387,9 +7137,9 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "engines": { "node": ">=16 || 14 >=14.17" } @@ -6407,17 +7157,17 @@ } }, "node_modules/minipass-fetch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz", - "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", "dev": true, "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" + "minizlib": "^3.0.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" }, "optionalDependencies": { "encoding": "^0.1.13" @@ -6514,36 +7264,33 @@ "dev": true }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", "dev": true, "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.0.4", + "rimraf": "^5.0.5" }, "engines": { - "node": ">= 8" + "node": ">= 18" } }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/minizlib/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", "dev": true, "dependencies": { - "yallist": "^4.0.0" + "glob": "^10.3.7" }, - "engines": { - "node": ">=8" + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -6557,9 +7304,9 @@ } }, "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "engines": { "node": ">=10" @@ -6571,10 +7318,11 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/msgpackr": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.0.tgz", - "integrity": "sha512-I8qXuuALqJe5laEBYoFykChhSXLikZmUhccjGsPuSJ/7uPip2TJ7lwdIQwWSAi0jGZDXv4WOP8Qg65QZRuXxXw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz", + "integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==", "dev": true, + "optional": true, "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -6602,18 +7350,18 @@ } }, "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -6635,9 +7383,9 @@ "dev": true }, "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, "engines": { "node": ">= 0.6" @@ -6670,23 +7418,23 @@ } }, "node_modules/ng-select2-component": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-14.0.1.tgz", - "integrity": "sha512-eynfQXr2rtAfi6ex9E6LWOrPeG2YjoU7nzBpa0XFbxGgapXRInA+HJyASwjHN95kLY4UJs+lkG2LfBAoDXk1qg==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-17.2.4.tgz", + "integrity": "sha512-pfRQg1gY1NsQkBNAYYeSYJjejKwz1z+9bKWor8/8toCNbvh9TYMOKpcz3FrNvhR6v/Hto/quddajaxjD81TOgg==", "dependencies": { - "ngx-infinite-scroll": ">=17.0.0", + "ngx-infinite-scroll": ">=18.0.0 || >=19.0.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": ">=17.0.0", - "@angular/common": ">=17.0.0", - "@angular/core": ">=17.0.0" + "@angular/cdk": ">=18.1.0 || >=19.0.0", + "@angular/common": ">=18.1.0 || >=19.0.0", + "@angular/core": ">=18.1.0 || >=19.0.0" } }, "node_modules/ngx-color-picker": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-17.0.0.tgz", - "integrity": "sha512-kHuhW4vErpb0LlBlgTnf1+cYWdaq0gOvDwiX9LeaFKNvhV5li+YEyk7tC3o1Sbhqd4lsFKb8zHyqi1teLWN4Zg==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-19.0.0.tgz", + "integrity": "sha512-jZs7nk/DJB6FryElYnfkojWYCgpEc650s800g+39ebocVMZ18fAHf/CQd5+Bdm4E3zoRod0a0sErJ+c8tGQcCg==", "dependencies": { "tslib": "^2.3.0" }, @@ -6697,15 +7445,15 @@ } }, "node_modules/ngx-extended-pdf-viewer": { - "version": "21.4.6", - "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-21.4.6.tgz", - "integrity": "sha512-+YlRznVS4tXdYYrWNRGCVY+9wJOi98giBxhYMMYFHt8FTLYPP+Fn1x3ovPnFngFYC47QEe1To3HqBVrF0Q/vZQ==", + "version": "23.0.0-alpha.7", + "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-23.0.0-alpha.7.tgz", + "integrity": "sha512-S5jI9Z6p6wglLwvpf85MddxGKYUiJczb02nZcFWztDSZ7BlKXkjdtssW+chBOc/sg46p2kTDoa0M/R07yqRFcA==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=16.0.0 <19.0.0 || ~18.1.0-rc.0", - "@angular/core": ">=16.0.0 <19.0.0 || ~18.1.0-rc.0" + "@angular/common": ">=17.0.0 <20.0.0", + "@angular/core": ">=17.0.0 <20.0.0" } }, "node_modules/ngx-file-drop": { @@ -6725,15 +7473,15 @@ } }, "node_modules/ngx-infinite-scroll": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-18.0.0.tgz", - "integrity": "sha512-D183TDwpsd9Zl56UmItsl3RzHdN25srAISfg6lc3A8mEKkEgOq0s7ZzRAYcx8DHsAkMgtZqjIPEvMifD3DOB/g==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-19.0.0.tgz", + "integrity": "sha512-Ft4xNNDLXoDGi2hF6ylehjxbG8JIgfoL6qDWWcebGMcbh1CEfEsh0HGkDuFlX/cBBMenRh2HFbXlYq8BAtbvLw==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=18.0.0 <19.0.0", - "@angular/core": ">=18.0.0 <19.0.0" + "@angular/common": ">=19.0.0 <20.0.0", + "@angular/core": ">=19.0.0 <20.0.0" } }, "node_modules/ngx-stars": { @@ -6761,33 +7509,12 @@ "@angular/platform-browser": ">=16.0.0-0" } }, - "node_modules/nice-napi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", - "integrity": "sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "!win32" - ], - "dependencies": { - "node-addon-api": "^3.0.0", - "node-gyp-build": "^4.2.2" - } - }, - "node_modules/nice-napi/node_modules/node-addon-api": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", - "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", - "dev": true, - "optional": true - }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "dev": true + "dev": true, + "optional": true }, "node_modules/node-fetch": { "version": "2.7.0", @@ -6809,39 +7536,27 @@ } }, "node_modules/node-gyp": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.2.0.tgz", - "integrity": "sha512-sp3FonBAaFe4aYTcFdZUn2NYkbP7xroPGYvQmP4Nl5PxamznItBnNCgjrVTKrEfQynInMsJvZrdmqUnysCJ8rw==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.1.0.tgz", + "integrity": "sha512-/+7TuHKnBpnMvUQnsYEb0JOozDZqarQbfNuSGLXIjhStMT0fbw7IdSqWgopOP5xhRZE+lsbIvAHcekddruPZgQ==", "dev": true, "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^10.3.10", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^13.0.0", - "nopt": "^7.0.0", - "proc-log": "^4.1.0", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", "semver": "^7.3.5", - "tar": "^6.2.1", - "which": "^4.0.0" + "tar": "^7.4.3", + "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.2", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.2.tgz", - "integrity": "sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw==", - "dev": true, - "optional": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/node-gyp-build-optional-packages": { @@ -6849,6 +7564,7 @@ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", "dev": true, + "optional": true, "dependencies": { "detect-libc": "^2.0.1" }, @@ -6858,6 +7574,15 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/node-gyp/node_modules/isexe": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", @@ -6867,10 +7592,42 @@ "node": ">=16" } }, + "node_modules/node-gyp/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/node-gyp/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -6879,7 +7636,16 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "engines": { + "node": ">=18" } }, "node_modules/node-releases": { @@ -6888,41 +7654,18 @@ "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" }, "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", "dev": true, "dependencies": { - "abbrev": "^2.0.0" + "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", - "dev": true, - "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nosleep.js": { @@ -6931,97 +7674,97 @@ "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==" }, "node_modules/npm-bundled": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz", - "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", + "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", "dev": true, "dependencies": { - "npm-normalize-package-bin": "^3.0.0" + "npm-normalize-package-bin": "^4.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-install-checks": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", - "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", + "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", "dev": true, "dependencies": { "semver": "^7.1.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-normalize-package-bin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", - "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-package-arg": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", - "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", + "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", "dev": true, "dependencies": { - "hosted-git-info": "^7.0.0", - "proc-log": "^4.0.0", + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" + "validate-npm-package-name": "^6.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-packlist": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz", - "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", + "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", "dev": true, "dependencies": { - "ignore-walk": "^6.0.4" + "ignore-walk": "^7.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-pick-manifest": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.1.0.tgz", - "integrity": "sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", + "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", "dev": true, "dependencies": { - "npm-install-checks": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "npm-package-arg": "^11.0.0", + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", "semver": "^7.3.5" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-registry-fetch": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-17.1.0.tgz", - "integrity": "sha512-5+bKQRH0J1xG1uZ1zMNvxW0VEyoNWgJpY9UDuluPFLKDfJ9u2JmmjmTJV1srBGQOROfdBMiVvnH2Zvpbm+xkVA==", + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", + "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", "dev": true, "dependencies": { - "@npmcli/redact": "^2.0.0", + "@npmcli/redact": "^3.0.0", "jsonparse": "^1.3.1", - "make-fetch-happen": "^13.0.0", + "make-fetch-happen": "^14.0.0", "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minizlib": "^2.1.2", - "npm-package-arg": "^11.0.0", - "proc-log": "^4.0.0" + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nth-check": { @@ -7107,10 +7850,11 @@ } }, "node_modules/ordered-binary": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.2.tgz", - "integrity": "sha512-JTo+4+4Fw7FreyAvlSLjb1BBVaxEQAacmjD3jjuyPZclpbEghTvQZbXBb2qPd2LeIMxiHwXBZUcpmG2Gl/mDEA==", - "dev": true + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.3.tgz", + "integrity": "sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==", + "dev": true, + "optional": true }, "node_modules/os-tmpdir": { "version": "1.0.2", @@ -7122,49 +7866,46 @@ } }, "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/pacote": { - "version": "18.0.6", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-18.0.6.tgz", - "integrity": "sha512-+eK3G27SMwsB8kLIuj4h1FUhHtwiEUo21Tw8wNjmvdlpOEr613edv+8FUsTj/4F/VN5ywGE19X18N7CC2EJk6A==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.0.tgz", + "integrity": "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==", "dev": true, "dependencies": { - "@npmcli/git": "^5.0.0", - "@npmcli/installed-package-contents": "^2.0.1", - "@npmcli/package-json": "^5.1.0", - "@npmcli/promise-spawn": "^7.0.0", - "@npmcli/run-script": "^8.0.0", - "cacache": "^18.0.0", + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", - "npm-package-arg": "^11.0.0", - "npm-packlist": "^8.0.0", - "npm-pick-manifest": "^9.0.0", - "npm-registry-fetch": "^17.0.0", - "proc-log": "^4.0.0", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", - "sigstore": "^2.2.0", - "ssri": "^10.0.0", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", "tar": "^6.1.11" }, "bin": { "pacote": "bin/index.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/parent-module": { @@ -7209,7 +7950,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "devOptional": true, "dependencies": { "entities": "^4.4.0" }, @@ -7252,15 +7992,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -7324,62 +8055,18 @@ } }, "node_modules/piscina": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.6.1.tgz", - "integrity": "sha512-z30AwWGtQE+Apr+2WBZensP2lIvwoaMcOPkQlIEmSGMJNUvaYACylPYrQM6wSdUNJlnDVMSpLv7xTMJqlVshOA==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.8.0.tgz", + "integrity": "sha512-EZJb+ZxDrQf3dihsUL7p42pjNyrNIFJCrRHPMgxu/svsj+P3xS3fuEWp7k2+rfsavfl1N0G29b1HGs7J0m8rZA==", "dev": true, "optionalDependencies": { - "nice-napi": "^1.0.2" - } - }, - "node_modules/playwright": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz", - "integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==", - "dev": true, - "dependencies": { - "playwright-core": "1.49.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz", - "integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==", - "dev": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "@napi-rs/nice": "^1.0.1" } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -7396,9 +8083,9 @@ } ], "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -7420,20 +8107,14 @@ } }, "node_modules/proc-log": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", - "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true - }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -7497,34 +8178,11 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -7594,18 +8252,21 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7648,72 +8309,16 @@ "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true }, "node_modules/rollup": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", - "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", + "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", "dev": true, "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -7723,22 +8328,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.22.4", - "@rollup/rollup-android-arm64": "4.22.4", - "@rollup/rollup-darwin-arm64": "4.22.4", - "@rollup/rollup-darwin-x64": "4.22.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", - "@rollup/rollup-linux-arm-musleabihf": "4.22.4", - "@rollup/rollup-linux-arm64-gnu": "4.22.4", - "@rollup/rollup-linux-arm64-musl": "4.22.4", - "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", - "@rollup/rollup-linux-riscv64-gnu": "4.22.4", - "@rollup/rollup-linux-s390x-gnu": "4.22.4", - "@rollup/rollup-linux-x64-gnu": "4.22.4", - "@rollup/rollup-linux-x64-musl": "4.22.4", - "@rollup/rollup-win32-arm64-msvc": "4.22.4", - "@rollup/rollup-win32-ia32-msvc": "4.22.4", - "@rollup/rollup-win32-x64-msvc": "4.22.4", + "@rollup/rollup-android-arm-eabi": "4.34.8", + "@rollup/rollup-android-arm64": "4.34.8", + "@rollup/rollup-darwin-arm64": "4.34.8", + "@rollup/rollup-darwin-x64": "4.34.8", + "@rollup/rollup-freebsd-arm64": "4.34.8", + "@rollup/rollup-freebsd-x64": "4.34.8", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", + "@rollup/rollup-linux-arm-musleabihf": "4.34.8", + "@rollup/rollup-linux-arm64-gnu": "4.34.8", + "@rollup/rollup-linux-arm64-musl": "4.34.8", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", + "@rollup/rollup-linux-riscv64-gnu": "4.34.8", + "@rollup/rollup-linux-s390x-gnu": "4.34.8", + "@rollup/rollup-linux-x64-gnu": "4.34.8", + "@rollup/rollup-linux-x64-musl": "4.34.8", + "@rollup/rollup-win32-arm64-msvc": "4.34.8", + "@rollup/rollup-win32-ia32-msvc": "4.34.8", + "@rollup/rollup-win32-x64-msvc": "4.34.8", "fsevents": "~2.3.2" } }, @@ -7765,9 +8373,9 @@ } }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dependencies": { "tslib": "^2.1.0" } @@ -7795,16 +8403,16 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { - "version": "1.77.6", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz", - "integrity": "sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", + "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", "dev": true, "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -7812,6 +8420,37 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/screenfull": { @@ -7826,9 +8465,10 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -7872,20 +8512,20 @@ } }, "node_modules/sigstore": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz", - "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", + "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.3.2", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "@sigstore/sign": "^2.3.2", - "@sigstore/tuf": "^2.3.4", - "@sigstore/verify": "^1.2.1" + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/sirv": { @@ -7953,9 +8593,9 @@ } }, "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "dev": true, "dependencies": { "ip-address": "^9.0.5", @@ -7967,12 +8607,12 @@ } }, "node_modules/socks-proxy-agent": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", - "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, "dependencies": { - "agent-base": "^7.1.1", + "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" }, @@ -7998,6 +8638,25 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -8025,9 +8684,15 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", - "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, "node_modules/ssr-window": { @@ -8036,15 +8701,15 @@ "integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==" }, "node_modules/ssri": { - "version": "10.0.6", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", - "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", "dev": true, "dependencies": { "minipass": "^7.0.3" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/string_decoder": { @@ -8222,17 +8887,62 @@ "node": ">=8" } }, + "node_modules/tar/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tar/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" + }, + "node_modules/tinygradient": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", + "dependencies": { + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -8282,15 +8992,15 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-node": { @@ -8337,22 +9047,22 @@ } }, "node_modules/tslib": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tuf-js": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz", - "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", + "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", "dev": true, "dependencies": { - "@tufjs/models": "2.0.1", - "debug": "^4.3.4", - "make-fetch-happen": "^13.0.1" + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/type-check": { @@ -8383,6 +9093,7 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8392,33 +9103,33 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "dev": true }, "node_modules/unique-filename": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", - "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", "dev": true, "dependencies": { - "unique-slug": "^4.0.0" + "unique-slug": "^5.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/unique-slug": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", - "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", "dev": true, "dependencies": { "imurmurhash": "^0.1.4" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/universalify": { @@ -8498,29 +9209,29 @@ } }, "node_modules/validate-npm-package-name": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", - "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", + "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/vite": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", - "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", + "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", "dev": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -8529,19 +9240,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -8562,447 +9279,19 @@ }, "terser": { "optional": true - } - } - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vite/node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "tsx": { + "optional": true }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" + "yaml": { + "optional": true } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" } }, "node_modules/watchpack": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", - "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", @@ -9024,7 +9313,8 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", - "dev": true + "dev": true, + "optional": true }, "node_modules/webidl-conversions": { "version": "3.0.1", @@ -9229,9 +9519,9 @@ } }, "node_modules/zone.js": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.10.tgz", - "integrity": "sha512-YGAhaO7J5ywOXW6InXNlLmfU194F8lVgu7bRntUF3TiG8Y3nBK0x1UJJuHUP/e8IyihkjCYqhCScpSwnlaSRkQ==" + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", + "integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==" } } } diff --git a/UI/Web/package.json b/UI/Web/package.json index c6913319f..05d539aed 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -12,70 +12,70 @@ "prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale", "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "lint": "ng lint", - "e2e": "npx playwright test --ui" + "e2e": "ng e2e" }, "private": true, "dependencies": { - "@angular-slider/ngx-slider": "^18.0.0", - "@angular/animations": "^18.2.9", - "@angular/cdk": "^18.2.10", - "@angular/common": "^18.2.9", - "@angular/compiler": "^18.2.9", - "@angular/core": "^18.2.9", - "@angular/forms": "^18.2.9", - "@angular/localize": "^18.2.9", - "@angular/platform-browser": "^18.2.9", - "@angular/platform-browser-dynamic": "^18.2.9", - "@angular/router": "^18.2.9", - "@fortawesome/fontawesome-free": "^6.6.0", - "@iharbeck/ngx-virtual-scroller": "^17.0.2", - "@iplab/ngx-file-upload": "^18.0.0", - "@jsverse/transloco": "^7.5.0", + "@angular-slider/ngx-slider": "^19.0.0", + "@angular/animations": "^19.2.5", + "@angular/cdk": "^19.2.8", + "@angular/common": "^19.2.5", + "@angular/compiler": "^19.2.5", + "@angular/core": "^19.2.5", + "@angular/forms": "^19.2.5", + "@angular/localize": "^19.2.5", + "@angular/platform-browser": "^19.2.5", + "@angular/platform-browser-dynamic": "^19.2.5", + "@angular/router": "^19.2.5", + "@fortawesome/fontawesome-free": "^6.7.2", + "@iharbeck/ngx-virtual-scroller": "^19.0.1", + "@iplab/ngx-file-upload": "^19.0.3", + "@jsverse/transloco": "^7.6.1", "@jsverse/transloco-locale": "^7.0.1", "@jsverse/transloco-persist-lang": "^7.0.2", "@jsverse/transloco-persist-translations": "^7.0.1", "@jsverse/transloco-preload-langs": "^7.0.1", "@microsoft/signalr": "^8.0.7", - "@ng-bootstrap/ng-bootstrap": "^17.0.1", + "@ng-bootstrap/ng-bootstrap": "^18.0.0", "@popperjs/core": "^2.11.7", - "@swimlane/ngx-charts": "^20.5.0", - "@tweenjs/tween.js": "^23.1.3", + "@siemens/ngx-datatable": "^22.4.1", + "@swimlane/ngx-charts": "^22.0.0-alpha.0", + "@tweenjs/tween.js": "^25.0.0", "bootstrap": "^5.3.2", "charts.css": "^1.1.0", "file-saver": "^2.0.5", - "luxon": "^3.5.0", + "luxon": "^3.6.1", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", - "ng-select2-component": "^14.0.1", - "ngx-color-picker": "^17.0.0", - "ngx-extended-pdf-viewer": "^21.4.6", + "ng-select2-component": "^17.2.4", + "ngx-color-picker": "^19.0.0", + "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-file-drop": "^16.0.0", "ngx-stars": "^1.6.5", "ngx-toastr": "^19.0.0", "nosleep.js": "^0.12.0", - "rxjs": "^7.8.0", + "rxjs": "^7.8.2", "screenfull": "^6.0.2", "swiper": "^8.4.6", - "tslib": "^2.8.0", - "zone.js": "^0.14.10" + "tslib": "^2.8.1", + "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-eslint/builder": "^18.4.0", - "@angular-eslint/eslint-plugin": "^18.4.0", - "@angular-eslint/eslint-plugin-template": "^18.4.0", - "@angular-eslint/schematics": "^18.4.0", - "@angular-eslint/template-parser": "^18.4.0", - "@angular/build": "^18.2.10", - "@angular/cli": "^18.2.10", - "@angular/compiler-cli": "^18.2.9", - "@playwright/test": "^1.49.0", + "@angular-eslint/builder": "^19.3.0", + "@angular-eslint/eslint-plugin": "^19.3.0", + "@angular-eslint/eslint-plugin-template": "^19.3.0", + "@angular-eslint/schematics": "^19.3.0", + "@angular-eslint/template-parser": "^19.3.0", + "@angular/build": "^19.2.6", + "@angular/cli": "^19.2.6", + "@angular/compiler-cli": "^19.2.5", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", - "@types/luxon": "^3.4.0", - "@types/node": "^22.8.0", - "@typescript-eslint/eslint-plugin": "^8.11.0", - "@typescript-eslint/parser": "^8.11.0", - "eslint": "^8.57.0", + "@types/luxon": "^3.6.2", + "@types/node": "^22.13.13", + "@typescript-eslint/eslint-plugin": "^8.28.0", + "@typescript-eslint/parser": "^8.28.0", + "eslint": "^9.23.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", "ts-node": "~10.9.1", diff --git a/UI/Web/playwright.config.ts b/UI/Web/playwright.config.ts deleted file mode 100644 index 86297eba9..000000000 --- a/UI/Web/playwright.config.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './e2e-tests', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://127.0.0.1:3000', - // reuseExistingServer: !process.env.CI, - // }, -}); diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss index f27a8768b..1c6f916f3 100644 --- a/UI/Web/src/_card-item-common.scss +++ b/UI/Web/src/_card-item-common.scss @@ -163,11 +163,15 @@ $image-width: 160px; align-items: center; padding: 0 5px; + :first-child { + min-width: 22px; + } + .card-title { font-size: 0.8rem; margin: 0; text-align: center; - max-width: 98px; + max-width: 90px; a { overflow: hidden; diff --git a/UI/Web/src/_series-detail-common.scss b/UI/Web/src/_series-detail-common.scss index 214047a93..efb54f860 100644 --- a/UI/Web/src/_series-detail-common.scss +++ b/UI/Web/src/_series-detail-common.scss @@ -1,4 +1,4 @@ -@import './theme/variables'; +@use './theme/variables' as theme; .title { color: white; @@ -13,7 +13,7 @@ } .subtitle { - color: lightgrey; + color: var(--detail-subtitle-color); font-weight: bold; font-size: 0.8rem; } @@ -149,7 +149,7 @@ object-fit: contain; } -@media (max-width: $grid-breakpoints-lg) { +@media (max-width: theme.$grid-breakpoints-lg) { .carousel-tabs-container { mask-image: linear-gradient(transparent, black 0%, black 90%, transparent 100%); -webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); @@ -162,7 +162,7 @@ } /* col-lg */ -@media (max-width: $grid-breakpoints-lg) { +@media (max-width: theme.$grid-breakpoints-lg) { .image-container.mobile-bg{ width: 100vw; top: calc(var(--nav-offset) - 20px); @@ -201,7 +201,7 @@ font-size: 0.9rem; } -@media (max-width: $grid-breakpoints-lg) { +@media (max-width: theme.$grid-breakpoints-lg) { .carousel-tabs-container { mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); -webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); diff --git a/UI/Web/src/_tag-card-common.scss b/UI/Web/src/_tag-card-common.scss new file mode 100644 index 000000000..39a1e87fd --- /dev/null +++ b/UI/Web/src/_tag-card-common.scss @@ -0,0 +1,35 @@ +.tag-card { + background-color: var(--bs-card-color, #2c2c2c); + padding: 1rem; + border-radius: 12px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + transition: transform 0.2s ease, background 0.3s ease; + cursor: pointer; + + &.not-selectable:hover { + cursor: not-allowed; + background-color: var(--bs-card-color, #2c2c2c) !important; + } +} + +.tag-card:hover { + background-color: #3a3a3a; + //transform: translateY(-3px); // Cool effect but has a weird background issue. ROBBIE: Fix this +} + +.tag-name { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.5rem; + max-height: 8rem; + height: 8rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.tag-meta { + font-size: 0.85rem; + display: flex; + justify-content: space-between; + color: var(--text-muted-color, #bbb); +} diff --git a/UI/Web/src/app/_directives/dbl-click.directive.ts b/UI/Web/src/app/_directives/dbl-click.directive.ts index 98fabf843..ab1d0bcde 100644 --- a/UI/Web/src/app/_directives/dbl-click.directive.ts +++ b/UI/Web/src/app/_directives/dbl-click.directive.ts @@ -6,21 +6,30 @@ import {Directive, EventEmitter, HostListener, Output} from '@angular/core'; }) export class DblClickDirective { + @Output() singleClick = new EventEmitter(); @Output() doubleClick = new EventEmitter(); private lastTapTime = 0; private tapTimeout = 300; // Time threshold for a double tap (in milliseconds) + private singleClickTimeout: any; @HostListener('click', ['$event']) handleClick(event: Event): void { - event.stopPropagation(); - event.preventDefault(); - const currentTime = new Date().getTime(); + if (currentTime - this.lastTapTime < this.tapTimeout) { // Detected a double click/tap + clearTimeout(this.singleClickTimeout); // Prevent single-click emission + event.stopPropagation(); + event.preventDefault(); this.doubleClick.emit(event); + } else { + // Delay single-click emission to check if a double-click occurs + this.singleClickTimeout = setTimeout(() => { + this.singleClick.emit(event); // Optional: emit single-click if no double-click follows + }, this.tapTimeout); } + this.lastTapTime = currentTime; } diff --git a/UI/Web/src/app/_directives/enter-blur.directive.ts b/UI/Web/src/app/_directives/enter-blur.directive.ts new file mode 100644 index 000000000..30329f724 --- /dev/null +++ b/UI/Web/src/app/_directives/enter-blur.directive.ts @@ -0,0 +1,13 @@ +import { Directive, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appEnterBlur]', + standalone: true, +}) +export class EnterBlurDirective { + @HostListener('keydown.enter', ['$event']) + onEnter(event: KeyboardEvent): void { + event.preventDefault(); + document.body.click(); + } +} diff --git a/UI/Web/src/app/_helpers/browser.ts b/UI/Web/src/app/_helpers/browser.ts new file mode 100644 index 000000000..4d92e207c --- /dev/null +++ b/UI/Web/src/app/_helpers/browser.ts @@ -0,0 +1,62 @@ +export const isSafari = [ + 'iPad Simulator', + 'iPhone Simulator', + 'iPod Simulator', + 'iPad', + 'iPhone', + 'iPod' + ].includes(navigator.platform) + // iPad on iOS 13 detection + || (navigator.userAgent.includes("Mac") && "ontouchend" in document); + +/** + * Represents a Version for a browser + */ +export class Version { + major: number; + minor: number; + patch: number; + + constructor(major: number, minor: number, patch: number) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + isLessThan(other: Version): boolean { + if (this.major < other.major) return true; + if (this.major > other.major) return false; + if (this.minor < other.minor) return true; + if (this.minor > other.minor) return false; + return this.patch < other.patch; + } + + isGreaterThan(other: Version): boolean { + if (this.major > other.major) return true; + if (this.major < other.major) return false; + if (this.minor > other.minor) return true; + if (this.minor < other.minor) return false; + return this.patch > other.patch; + } + + isEqualTo(other: Version): boolean { + return ( + this.major === other.major && + this.minor === other.minor && + this.patch === other.patch + ); + } +} + + +export const getIosVersion = () => { + const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/); + if (match) { + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + const patch = parseInt(match[3] || '0', 10); + + return new Version(major, minor, patch); + } + return null; +} diff --git a/UI/Web/src/app/_helpers/form-debug.ts b/UI/Web/src/app/_helpers/form-debug.ts new file mode 100644 index 000000000..4ad70ac87 --- /dev/null +++ b/UI/Web/src/app/_helpers/form-debug.ts @@ -0,0 +1,120 @@ +import {AbstractControl, FormArray, FormControl, FormGroup} from '@angular/forms'; + +interface ValidationIssue { + path: string; + controlType: string; + value: any; + errors: { [key: string]: any } | null; + status: string; + disabled: boolean; +} + +export function analyzeFormGroupValidation(formGroup: FormGroup, basePath: string = ''): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + function analyzeControl(control: AbstractControl, path: string): void { + // Determine control type for better debugging + let controlType = 'AbstractControl'; + if (control instanceof FormGroup) { + controlType = 'FormGroup'; + } else if (control instanceof FormArray) { + controlType = 'FormArray'; + } else if (control instanceof FormControl) { + controlType = 'FormControl'; + } + + // Add issue if control has validation errors or is invalid + if (control.invalid || control.errors || control.disabled) { + issues.push({ + path: path || 'root', + controlType, + value: control.value, + errors: control.errors, + status: control.status, + disabled: control.disabled + }); + } + + // Recursively check nested controls + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + const childPath = path ? `${path}.${key}` : key; + analyzeControl(control.controls[key], childPath); + }); + } else if (control instanceof FormArray) { + control.controls.forEach((childControl, index) => { + const childPath = path ? `${path}[${index}]` : `[${index}]`; + analyzeControl(childControl, childPath); + }); + } + } + + analyzeControl(formGroup, basePath); + return issues; +} + +export function printFormGroupValidation(formGroup: FormGroup, basePath: string = ''): void { + const issues = analyzeFormGroupValidation(formGroup, basePath); + + console.group(`🔍 FormGroup Validation Analysis (${basePath || 'root'})`); + console.log(`Overall Status: ${formGroup.status}`); + console.log(`Overall Valid: ${formGroup.valid}`); + console.log(`Total Issues Found: ${issues.length}`); + + if (issues.length === 0) { + console.log('✅ No validation issues found!'); + } else { + console.log('\n📋 Detailed Issues:'); + issues.forEach((issue, index) => { + console.group(`${index + 1}. ${issue.path} (${issue.controlType})`); + console.log(`Status: ${issue.status}`); + console.log(`Value:`, issue.value); + console.log(`Disabled: ${issue.disabled}`); + + if (issue.errors) { + console.log('Validation Errors:'); + Object.entries(issue.errors).forEach(([errorKey, errorValue]) => { + console.log(` • ${errorKey}:`, errorValue); + }); + } else { + console.log('No specific validation errors (but control is invalid)'); + } + console.groupEnd(); + }); + } + + console.groupEnd(); +} + +// Alternative function that returns a formatted string instead of console logging +export function getFormGroupValidationReport(formGroup: FormGroup, basePath: string = ''): string { + const issues = analyzeFormGroupValidation(formGroup, basePath); + + let report = `FormGroup Validation Report (${basePath || 'root'})\n`; + report += `Overall Status: ${formGroup.status}\n`; + report += `Overall Valid: ${formGroup.valid}\n`; + report += `Total Issues Found: ${issues.length}\n\n`; + + if (issues.length === 0) { + report += '✅ No validation issues found!'; + } else { + report += 'Detailed Issues:\n'; + issues.forEach((issue, index) => { + report += `\n${index + 1}. ${issue.path} (${issue.controlType})\n`; + report += ` Status: ${issue.status}\n`; + report += ` Value: ${JSON.stringify(issue.value)}\n`; + report += ` Disabled: ${issue.disabled}\n`; + + if (issue.errors) { + report += ' Validation Errors:\n'; + Object.entries(issue.errors).forEach(([errorKey, errorValue]) => { + report += ` • ${errorKey}: ${JSON.stringify(errorValue)}\n`; + }); + } else { + report += ' No specific validation errors (but control is invalid)\n'; + } + }); + } + + return report; +} diff --git a/UI/Web/src/app/_models/auth/member.ts b/UI/Web/src/app/_models/auth/member.ts index 31238c68b..aaa45f332 100644 --- a/UI/Web/src/app/_models/auth/member.ts +++ b/UI/Web/src/app/_models/auth/member.ts @@ -1,16 +1,16 @@ -import { AgeRestriction } from '../metadata/age-restriction'; -import { Library } from '../library/library'; +import {AgeRestriction} from '../metadata/age-restriction'; +import {Library} from '../library/library'; export interface Member { - id: number; - username: string; - email: string; - lastActive: string; // datetime - lastActiveUtc: string; // datetime - created: string; // datetime - createdUtc: string; // datetime - roles: string[]; - libraries: Library[]; - ageRestriction: AgeRestriction; - isPending: boolean; + id: number; + username: string; + email: string; + lastActive: string; // datetime + lastActiveUtc: string; // datetime + created: string; // datetime + createdUtc: string; // datetime + roles: string[]; + libraries: Library[]; + ageRestriction: AgeRestriction; + isPending: boolean; } diff --git a/UI/Web/src/app/_models/chapter-detail-plus.ts b/UI/Web/src/app/_models/chapter-detail-plus.ts new file mode 100644 index 000000000..2a17089e1 --- /dev/null +++ b/UI/Web/src/app/_models/chapter-detail-plus.ts @@ -0,0 +1,9 @@ +import {UserReview} from "../_single-module/review-card/user-review"; +import {Rating} from "./rating"; + +export type ChapterDetailPlus = { + rating: number; + hasBeenRated: boolean; + reviews: UserReview[]; + ratings: Rating[]; +}; diff --git a/UI/Web/src/app/_models/email-history.ts b/UI/Web/src/app/_models/email-history.ts new file mode 100644 index 000000000..0805704fb --- /dev/null +++ b/UI/Web/src/app/_models/email-history.ts @@ -0,0 +1,7 @@ +export interface EmailHistory { + sent: boolean; + sendDate: string; + emailTemplate: string; + errorMessage: string; + toUserName: string; +} diff --git a/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts b/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts new file mode 100644 index 000000000..3695651d6 --- /dev/null +++ b/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts @@ -0,0 +1,4 @@ +export interface ExternalMatchRateLimitErrorEvent { + seriesId: number; + seriesName: string; +} diff --git a/UI/Web/src/app/_models/events/update-version-event.ts b/UI/Web/src/app/_models/events/update-version-event.ts index c74e49af6..4e7e82ce6 100644 --- a/UI/Web/src/app/_models/events/update-version-event.ts +++ b/UI/Web/src/app/_models/events/update-version-event.ts @@ -1,12 +1,26 @@ export interface UpdateVersionEvent { - currentVersion: string; - updateVersion: string; - updateBody: string; - updateTitle: string; - updateUrl: string; - isDocker: boolean; - publishDate: string; - isOnNightlyInRelease: boolean; - isReleaseNewer: boolean; - isReleaseEqual: boolean; + currentVersion: string; + updateVersion: string; + updateBody: string; + updateTitle: string; + updateUrl: string; + isDocker: boolean; + publishDate: string; + isOnNightlyInRelease: boolean; + isReleaseNewer: boolean; + isReleaseEqual: boolean; + + added: Array; + removed: Array; + changed: Array; + fixed: Array; + theme: Array; + developer: Array; + api: Array; + featureRequests: Array; + knownIssues: Array; + /** + * The part above the changelog part + */ + blogPart: string; } diff --git a/UI/Web/src/app/_models/kavitaplus/license-info.ts b/UI/Web/src/app/_models/kavitaplus/license-info.ts new file mode 100644 index 000000000..4a724b3ff --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/license-info.ts @@ -0,0 +1,9 @@ +export interface LicenseInfo { + expirationDate: string; + isActive: boolean; + isCancelled: boolean; + isValidVersion: boolean; + registeredEmail: string; + totalMonthsSubbed: number; + hasLicense: boolean; +} diff --git a/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts new file mode 100644 index 000000000..05a4041c8 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts @@ -0,0 +1,8 @@ +import {MatchStateOption} from "./match-state-option"; +import {LibraryType} from "../library/library"; + +export interface ManageMatchFilter { + matchStateOption: MatchStateOption; + libraryType: LibraryType | -1; + searchTerm: string; +} diff --git a/UI/Web/src/app/_models/kavitaplus/manage-match-series.ts b/UI/Web/src/app/_models/kavitaplus/manage-match-series.ts new file mode 100644 index 000000000..4138279e6 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/manage-match-series.ts @@ -0,0 +1,7 @@ +import {Series} from "../series"; + +export interface ManageMatchSeries { + series: Series; + isMatched: boolean; + validUntilUtc: string; +} diff --git a/UI/Web/src/app/_models/kavitaplus/match-state-option.ts b/UI/Web/src/app/_models/kavitaplus/match-state-option.ts new file mode 100644 index 000000000..a52c5efad --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/match-state-option.ts @@ -0,0 +1,11 @@ +export enum MatchStateOption { + All = 0, + Matched = 1, + NotMatched = 2, + Error = 3, + DontMatch = 4 +} + +export const allMatchStates = [ + MatchStateOption.Matched, MatchStateOption.NotMatched, MatchStateOption.Error, MatchStateOption.DontMatch +]; diff --git a/UI/Web/src/app/_models/kavitaplus/user-token-info.ts b/UI/Web/src/app/_models/kavitaplus/user-token-info.ts new file mode 100644 index 000000000..1dcab9c91 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/user-token-info.ts @@ -0,0 +1,8 @@ +export interface UserTokenInfo { + userId: number; + username: string; + isAniListTokenSet: boolean; + aniListValidUntilUtc: string; + isAniListTokenValid: boolean; + isMalTokenSet: boolean; +} diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index f834f23b1..bcbf9b447 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -1,5 +1,4 @@ import {FileTypeGroup} from "./file-type-group.enum"; -import {IHasCover} from "../common/i-has-cover"; export enum LibraryType { Manga = 0, @@ -7,9 +6,16 @@ export enum LibraryType { Book = 2, Images = 3, LightNovel = 4, + /** + * Comic (Legacy) + */ ComicVine = 5 } +export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images]; +export const allKavitaPlusMetadataApplicableTypes = [LibraryType.Manga, LibraryType.LightNovel, LibraryType.ComicVine, LibraryType.Comic]; +export const allKavitaPlusScrobbleEligibleTypes = [LibraryType.Manga, LibraryType.LightNovel]; + export interface Library { id: number; name: string; @@ -24,6 +30,9 @@ export interface Library { manageCollections: boolean; manageReadingLists: boolean; allowScrobbling: boolean; + allowMetadataMatching: boolean; + enableMetadata: boolean; + removePrefixForSortName: boolean; collapseSeriesRelationships: boolean; libraryFileTypes: Array; excludePatterns: Array; diff --git a/UI/Web/src/app/_models/metadata/browse/browse-genre.ts b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts new file mode 100644 index 000000000..e7bb0d915 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts @@ -0,0 +1,6 @@ +import {Genre} from "../genre"; + +export interface BrowseGenre extends Genre { + seriesCount: number; + chapterCount: number; +} diff --git a/UI/Web/src/app/_models/person/browse-person.ts b/UI/Web/src/app/_models/metadata/browse/browse-person.ts similarity index 52% rename from UI/Web/src/app/_models/person/browse-person.ts rename to UI/Web/src/app/_models/metadata/browse/browse-person.ts index aeddac7cd..886f9455b 100644 --- a/UI/Web/src/app/_models/person/browse-person.ts +++ b/UI/Web/src/app/_models/metadata/browse/browse-person.ts @@ -1,6 +1,6 @@ -import {Person} from "../metadata/person"; +import {Person} from "../person"; export interface BrowsePerson extends Person { seriesCount: number; - issueCount: number; + chapterCount: number; } diff --git a/UI/Web/src/app/_models/metadata/browse/browse-tag.ts b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts new file mode 100644 index 000000000..4d87370ee --- /dev/null +++ b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts @@ -0,0 +1,6 @@ +import {Tag} from "../../tag"; + +export interface BrowseTag extends Tag { + seriesCount: number; + chapterCount: number; +} diff --git a/UI/Web/src/app/_models/metadata/language.ts b/UI/Web/src/app/_models/metadata/language.ts index e8f606bec..28ab2b598 100644 --- a/UI/Web/src/app/_models/metadata/language.ts +++ b/UI/Web/src/app/_models/metadata/language.ts @@ -3,3 +3,13 @@ export interface Language { title: string; } +export interface KavitaLocale { + /** + * isoCode aka what maps to the file on disk and what transloco loads + */ + fileName: string; + renderName: string; + translationCompletion: number; + isRtL: boolean; + hash: string; +} diff --git a/UI/Web/src/app/_models/metadata/person.ts b/UI/Web/src/app/_models/metadata/person.ts index c8a4c566e..efc8df914 100644 --- a/UI/Web/src/app/_models/metadata/person.ts +++ b/UI/Web/src/app/_models/metadata/person.ts @@ -2,7 +2,6 @@ import {IHasCover} from "../common/i-has-cover"; export enum PersonRole { Other = 1, - Artist = 2, Writer = 3, Penciller = 4, Inker = 5, @@ -22,6 +21,7 @@ export interface Person extends IHasCover { id: number; name: string; description: string; + aliases: Array; coverImage?: string; coverImageLocked: boolean; malId?: number; @@ -31,3 +31,22 @@ export interface Person extends IHasCover { primaryColor: string; secondaryColor: string; } + +/** + * Excludes Other as it's not in use + */ +export const allPeopleRoles = [ + PersonRole.Writer, + PersonRole.Penciller, + PersonRole.Inker, + PersonRole.Colorist, + PersonRole.Letterer, + PersonRole.CoverArtist, + PersonRole.Editor, + PersonRole.Publisher, + PersonRole.Character, + PersonRole.Translator, + PersonRole.Imprint, + PersonRole.Team, + PersonRole.Location +] diff --git a/UI/Web/src/app/_models/metadata/series-filter.ts b/UI/Web/src/app/_models/metadata/series-filter.ts index bfaee4f3f..7875732b7 100644 --- a/UI/Web/src/app/_models/metadata/series-filter.ts +++ b/UI/Web/src/app/_models/metadata/series-filter.ts @@ -1,6 +1,5 @@ -import { MangaFormat } from "../manga-format"; -import { SeriesFilterV2 } from "./v2/series-filter-v2"; -import {FilterField} from "./v2/filter-field"; +import {MangaFormat} from "../manga-format"; +import {FilterV2} from "./v2/filter-v2"; export interface FilterItem { title: string; @@ -8,10 +7,6 @@ export interface FilterItem { selected: boolean; } -export interface SortOptions { - sortField: SortField; - isAscending: boolean; -} export enum SortField { SortName = 1, @@ -28,35 +23,35 @@ export enum SortField { Random = 9 } -export const allSortFields = Object.keys(SortField) +export const allSeriesSortFields = Object.keys(SortField) .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) .map(key => parseInt(key, 10)) as SortField[]; export const mangaFormatFilters = [ { - title: 'Images', + title: 'images', value: MangaFormat.IMAGE, selected: false }, { - title: 'EPUB', + title: 'epub', value: MangaFormat.EPUB, selected: false }, { - title: 'PDF', + title: 'pdf', value: MangaFormat.PDF, selected: false }, { - title: 'ARCHIVE', + title: 'archive', value: MangaFormat.ARCHIVE, selected: false } ]; -export interface FilterEvent { - filterV2: SeriesFilterV2; +export interface FilterEvent { + filterV2: FilterV2; isFirst: boolean; } diff --git a/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts b/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts new file mode 100644 index 000000000..bb5edc9ce --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts @@ -0,0 +1,8 @@ +import {PersonRole} from "../person"; +import {PersonSortOptions} from "./sort-options"; + +export interface BrowsePersonFilter { + roles: Array; + query?: string; + sortOptions?: PersonSortOptions; +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts index 08005d5c8..eeb8c7853 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -48,7 +48,7 @@ const enumArray = Object.keys(FilterField) enumArray.sort((a, b) => a.value.localeCompare(b.value)); -export const allFields = enumArray +export const allSeriesFilterFields = enumArray .map(key => parseInt(key.key, 10))as FilterField[]; export const allPeople = [ @@ -66,7 +66,6 @@ export const allPeople = [ export const personRoleForFilterField = (role: PersonRole) => { switch (role) { - case PersonRole.Artist: return FilterField.CoverArtist; case PersonRole.Character: return FilterField.Characters; case PersonRole.Colorist: return FilterField.Colorist; case PersonRole.CoverArtist: return FilterField.CoverArtist; diff --git a/UI/Web/src/app/_models/metadata/v2/filter-statement.ts b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts index d031927a2..b14fe564d 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-statement.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts @@ -1,8 +1,7 @@ -import { FilterComparison } from "./filter-comparison"; -import { FilterField } from "./filter-field"; +import {FilterComparison} from "./filter-comparison"; -export interface FilterStatement { +export interface FilterStatement { comparison: FilterComparison; - field: FilterField; + field: T; value: string; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-v2.ts b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts new file mode 100644 index 000000000..77c064450 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts @@ -0,0 +1,11 @@ +import {FilterStatement} from "./filter-statement"; +import {FilterCombination} from "./filter-combination"; +import {SortOptions} from "./sort-options"; + +export interface FilterV2 { + name?: string; + statements: Array>; + combination: FilterCombination; + sortOptions?: SortOptions; + limitTo: number; +} diff --git a/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts new file mode 100644 index 000000000..6bfb5a0c1 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts @@ -0,0 +1,12 @@ +export enum PersonFilterField { + Role = 1, + Name = 2, + SeriesCount = 3, + ChapterCount = 4, +} + + +export const allPersonFilterFields = Object.keys(PersonFilterField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as PersonFilterField[]; + diff --git a/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts b/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts new file mode 100644 index 000000000..6bcb66925 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts @@ -0,0 +1,9 @@ +export enum PersonSortField { + Name = 1, + SeriesCount = 2, + ChapterCount = 3 +} + +export const allPersonSortFields = Object.keys(PersonSortField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as PersonSortField[]; diff --git a/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts b/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts deleted file mode 100644 index c13244644..000000000 --- a/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SortOptions } from "../series-filter"; -import {FilterStatement} from "./filter-statement"; -import {FilterCombination} from "./filter-combination"; - -export interface SeriesFilterV2 { - name?: string; - statements: Array; - combination: FilterCombination; - sortOptions?: SortOptions; - limitTo: number; -} diff --git a/UI/Web/src/app/_models/metadata/v2/sort-options.ts b/UI/Web/src/app/_models/metadata/v2/sort-options.ts new file mode 100644 index 000000000..ed68d6b9d --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/sort-options.ts @@ -0,0 +1,17 @@ +import {PersonSortField} from "./person-sort-field"; + +/** + * Series-based Sort options + */ +export interface SortOptions { + sortField: TSort; + isAscending: boolean; +} + +/** + * Person-based Sort Options + */ +export interface PersonSortOptions { + sortField: PersonSortField; + isAscending: boolean; +} diff --git a/UI/Web/src/app/_models/preferences/book-theme.ts b/UI/Web/src/app/_models/preferences/book-theme.ts index b6e37f6e4..cb321c110 100644 --- a/UI/Web/src/app/_models/preferences/book-theme.ts +++ b/UI/Web/src/app/_models/preferences/book-theme.ts @@ -1,7 +1,7 @@ -import { ThemeProvider } from "./site-theme"; +import {ThemeProvider} from "./site-theme"; /** - * Theme for the the book reader contents + * Theme for the book reader contents */ export interface BookTheme { name: string; diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index b6e364056..886c570e2 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,68 +1,20 @@ - -import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; -import { BookPageLayoutMode } from '../readers/book-page-layout-mode'; -import { PageLayoutMode } from '../page-layout-mode'; -import { PageSplitOption } from './page-split-option'; -import { ReaderMode } from './reader-mode'; -import { ReadingDirection } from './reading-direction'; -import { ScalingOption } from './scaling-option'; -import { SiteTheme } from './site-theme'; -import {WritingStyle} from "./writing-style"; -import {PdfTheme} from "./pdf-theme"; -import {PdfScrollMode} from "./pdf-scroll-mode"; -import {PdfLayoutMode} from "./pdf-layout-mode"; -import {PdfSpreadMode} from "./pdf-spread-mode"; +import {PageLayoutMode} from '../page-layout-mode'; +import {SiteTheme} from './site-theme'; export interface Preferences { - // Manga Reader - readingDirection: ReadingDirection; - scalingOption: ScalingOption; - pageSplitOption: PageSplitOption; - readerMode: ReaderMode; - autoCloseMenu: boolean; - layoutMode: LayoutMode; - backgroundColor: string; - showScreenHints: boolean; - emulateBook: boolean; - swipeToPaginate: boolean; - // Book Reader - bookReaderMargin: number; - bookReaderLineSpacing: number; - bookReaderFontSize: number; - bookReaderFontFamily: string; - bookReaderTapToPaginate: boolean; - bookReaderReadingDirection: ReadingDirection; - bookReaderWritingStyle: WritingStyle; - bookReaderThemeName: string; - bookReaderLayoutMode: BookPageLayoutMode; - bookReaderImmersiveMode: boolean; + // Global + theme: SiteTheme; + globalPageLayoutMode: PageLayoutMode; + blurUnreadSummaries: boolean; + promptForDownloadSize: boolean; + noTransitions: boolean; + collapseSeriesRelationships: boolean; + shareReviews: boolean; + locale: string; - // PDF Reader - pdfTheme: PdfTheme; - pdfScrollMode: PdfScrollMode; - pdfSpreadMode: PdfSpreadMode; - - // Global - theme: SiteTheme; - globalPageLayoutMode: PageLayoutMode; - blurUnreadSummaries: boolean; - promptForDownloadSize: boolean; - noTransitions: boolean; - collapseSeriesRelationships: boolean; - shareReviews: boolean; - locale: string; + // Kavita+ + aniListScrobblingEnabled: boolean; + wantToReadSync: boolean; } -export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; -export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}]; -export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}]; -export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}]; -export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}]; -export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} -export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}]; -export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}]; -export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}]; -export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; -export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; -export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; diff --git a/UI/Web/src/app/_models/preferences/reading-profiles.ts b/UI/Web/src/app/_models/preferences/reading-profiles.ts new file mode 100644 index 000000000..dad02946f --- /dev/null +++ b/UI/Web/src/app/_models/preferences/reading-profiles.ts @@ -0,0 +1,80 @@ +import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode'; +import {BookPageLayoutMode} from '../readers/book-page-layout-mode'; +import {PageLayoutMode} from '../page-layout-mode'; +import {PageSplitOption} from './page-split-option'; +import {ReaderMode} from './reader-mode'; +import {ReadingDirection} from './reading-direction'; +import {ScalingOption} from './scaling-option'; +import {WritingStyle} from "./writing-style"; +import {PdfTheme} from "./pdf-theme"; +import {PdfScrollMode} from "./pdf-scroll-mode"; +import {PdfLayoutMode} from "./pdf-layout-mode"; +import {PdfSpreadMode} from "./pdf-spread-mode"; +import {Series} from "../series"; +import {Library} from "../library/library"; +import {UserBreakpoint} from "../../shared/_services/utility.service"; + +export enum ReadingProfileKind { + Default = 0, + User = 1, + Implicit = 2, +} + +export interface ReadingProfile { + + id: number; + name: string; + normalizedName: string; + kind: ReadingProfileKind; + + // Manga Reader + readingDirection: ReadingDirection; + scalingOption: ScalingOption; + pageSplitOption: PageSplitOption; + readerMode: ReaderMode; + autoCloseMenu: boolean; + layoutMode: LayoutMode; + backgroundColor: string; + showScreenHints: boolean; + emulateBook: boolean; + swipeToPaginate: boolean; + allowAutomaticWebtoonReaderDetection: boolean; + widthOverride?: number; + disableWidthOverride: UserBreakpoint; + + // Book Reader + bookReaderMargin: number; + bookReaderLineSpacing: number; + bookReaderFontSize: number; + bookReaderFontFamily: string; + bookReaderTapToPaginate: boolean; + bookReaderReadingDirection: ReadingDirection; + bookReaderWritingStyle: WritingStyle; + bookReaderThemeName: string; + bookReaderLayoutMode: BookPageLayoutMode; + bookReaderImmersiveMode: boolean; + + // PDF Reader + pdfTheme: PdfTheme; + pdfScrollMode: PdfScrollMode; + pdfSpreadMode: PdfSpreadMode; + + // relations + seriesIds: number[]; + libraryIds: number[]; + +} + +export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; +export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}]; +export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}]; +export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}]; +export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}]; +export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} +export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}]; +export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}]; +export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}]; +export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; +export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; +export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; +export const breakPoints = [UserBreakpoint.Never, UserBreakpoint.Mobile, UserBreakpoint.Tablet, UserBreakpoint.Desktop] diff --git a/UI/Web/src/app/_models/rating.ts b/UI/Web/src/app/_models/rating.ts index a4c4b79ed..7132706f9 100644 --- a/UI/Web/src/app/_models/rating.ts +++ b/UI/Web/src/app/_models/rating.ts @@ -1,9 +1,15 @@ import {ScrobbleProvider} from "../_services/scrobbling.service"; +export enum RatingAuthority { + User = 0, + Critic = 1, +} + export interface Rating { averageScore: number; meanScore: number; favoriteCount: number; provider: ScrobbleProvider; providerUrl: string | undefined; + authority: RatingAuthority; } diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index d5b115ad0..646360153 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -1,6 +1,9 @@ -import { LibraryType } from "./library/library"; -import { MangaFormat } from "./manga-format"; +import {LibraryType} from "./library/library"; +import {MangaFormat} from "./manga-format"; import {IHasCover} from "./common/i-has-cover"; +import {AgeRating} from "./metadata/age-rating"; +import {IHasReadingTime} from "./common/i-has-reading-time"; +import {IHasCast} from "./common/i-has-cast"; export interface ReadingListItem { pagesRead: number; @@ -30,13 +33,25 @@ export interface ReadingList extends IHasCover { items: Array; /** * If this is empty or null, the cover image isn't set. Do not use this externally. - */ - coverImage?: string; - primaryColor: string; - secondaryColor: string; - startingYear: number; - startingMonth: number; - endingYear: number; - endingMonth: number; + */ + coverImage?: string; + primaryColor: string; + secondaryColor: string; + startingYear: number; + startingMonth: number; + endingYear: number; + endingMonth: number; itemCount: number; + ageRating: AgeRating; } + +export interface ReadingListInfo extends IHasReadingTime, IHasReadingTime { + pages: number; + wordCount: number; + isAllEpub: boolean; + minHoursToRead: number; + maxHoursToRead: number; + avgHoursToRead: number; +} + +export interface ReadingListCast extends IHasCast {} diff --git a/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts b/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts index 102cf89d1..c0ea95d64 100644 --- a/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts +++ b/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts @@ -4,7 +4,8 @@ export enum ScrobbleEventSortField { LastModified = 2, Type= 3, Series = 4, - IsProcessed = 5 + IsProcessed = 5, + ScrobbleEvent = 6 } export interface ScrobbleEventFilter { diff --git a/UI/Web/src/app/_models/scrobbling/scrobble-event.ts b/UI/Web/src/app/_models/scrobbling/scrobble-event.ts index 48a75afda..7db1ceeaa 100644 --- a/UI/Web/src/app/_models/scrobbling/scrobble-event.ts +++ b/UI/Web/src/app/_models/scrobbling/scrobble-event.ts @@ -7,6 +7,7 @@ export enum ScrobbleEventType { } export interface ScrobbleEvent { + id: number; seriesName: string; seriesId: number; libraryId: number; diff --git a/UI/Web/src/app/_models/series-detail/external-series-detail.ts b/UI/Web/src/app/_models/series-detail/external-series-detail.ts index 85d89c760..db25782ca 100644 --- a/UI/Web/src/app/_models/series-detail/external-series-detail.ts +++ b/UI/Web/src/app/_models/series-detail/external-series-detail.ts @@ -27,8 +27,9 @@ export interface MetadataTagDto { export interface ExternalSeriesDetail { name: string; - aniListId?: number; - malId?: number; + aniListId?: number | null; + malId?: number | null; + cbrId?: number | null; synonyms: Array; plusMediaFormat: PlusMediaFormat; siteUrl?: string; @@ -37,6 +38,11 @@ export interface ExternalSeriesDetail { summary?: string; volumeCount?: number; chapterCount?: number; + /** + * These are duplicated with volumeCount based on where it's being invoked. + */ + volumes?: number; + chapters?: number; staff: Array; tags: Array; provider: ScrobbleProvider; diff --git a/UI/Web/src/app/_models/series-detail/external-series-match.ts b/UI/Web/src/app/_models/series-detail/external-series-match.ts new file mode 100644 index 000000000..28afea18a --- /dev/null +++ b/UI/Web/src/app/_models/series-detail/external-series-match.ts @@ -0,0 +1,6 @@ +import {ExternalSeriesDetail} from "./external-series-detail"; + +export interface ExternalSeriesMatch { + series: ExternalSeriesDetail; + matchRating: number; +} diff --git a/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts b/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts index f94ac569b..805a71178 100644 --- a/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts +++ b/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts @@ -1,6 +1,5 @@ -export interface HourEstimateRange{ +export interface HourEstimateRange { minHours: number; maxHours: number; avgHours: number; - //hasProgress: boolean; -} \ 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 8d4f773bb..29d4aed7f 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -5,70 +5,78 @@ import {IHasReadingTime} from "./common/i-has-reading-time"; import {IHasProgress} from "./common/i-has-progress"; export interface Series extends IHasCover, IHasReadingTime, IHasProgress { - id: number; - name: string; - /** - * This is not shown to user - */ - originalName: string; - localizedName: string; - sortName: string; - coverImageLocked: boolean; - sortNameLocked: boolean; - localizedNameLocked: boolean; - nameLocked: boolean; - volumes: Volume[]; - /** - * Total pages in series - */ - pages: number; - /** - * Total pages the logged in user has read - */ - pagesRead: number; - /** - * User's rating (0-5) - */ - userRating: number; - hasUserRated: boolean; - libraryId: number; - /** - * DateTime the entity was created - */ - created: string; - /** - * Format of the Series - */ - format: MangaFormat; - /** - * DateTime that represents last time the logged in user read this series - */ - latestReadDate: string; - /** - * DateTime representing last time a chapter was added to the Series - */ - lastChapterAdded: string; - /** - * DateTime representing last time the series folder was scanned - */ - lastFolderScanned: string; - /** - * Number of words in the series - */ - wordCount: number; - minHoursToRead: number; - maxHoursToRead: number; - avgHoursToRead: number; - /** - * Highest level folder containing this series - */ - folderPath: string; - lowestFolderPath: string; - /** - * This is currently only used on Series detail page for recommendations - */ - summary?: string; - coverImage?: string; - primaryColor: string; - secondaryColor: string; + id: number; + name: string; + /** + * This is not shown to user + */ + originalName: string; + localizedName: string; + sortName: string; + coverImageLocked: boolean; + sortNameLocked: boolean; + localizedNameLocked: boolean; + nameLocked: boolean; + volumes: Volume[]; + /** + * Total pages in series + */ + pages: number; + /** + * Total pages the logged in user has read + */ + pagesRead: number; + /** + * User's rating (0-5) + */ + userRating: number; + hasUserRated: boolean; + libraryId: number; + /** + * DateTime the entity was created + */ + created: string; + /** + * Format of the Series + */ + format: MangaFormat; + /** + * DateTime that represents last time the logged in user read this series + */ + latestReadDate: string; + /** + * DateTime representing last time a chapter was added to the Series + */ + lastChapterAdded: string; + /** + * DateTime representing last time the series folder was scanned + */ + lastFolderScanned: string; + /** + * Number of words in the series + */ + wordCount: number; + minHoursToRead: number; + maxHoursToRead: number; + avgHoursToRead: number; + /** + * Highest level folder containing this series + */ + folderPath: string; + lowestFolderPath: string; + /** + * This is currently only used on Series detail page for recommendations + */ + summary?: string; + coverImage?: string; + primaryColor: string; + secondaryColor: string; + /** + * Kavita+ only. Will not perform any matching from Kavita+ + */ + dontMatch: boolean; + /** + * Kavita+ only. Did this series not match and won't without manual match + */ + isBlacklisted: boolean; } diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index e5a74dbcf..c94a9485d 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -1,14 +1,16 @@ -import { AgeRestriction } from './metadata/age-restriction'; -import { Preferences } from './preferences/preferences'; +import {AgeRestriction} from './metadata/age-restriction'; +import {Preferences} from './preferences/preferences'; // This interface is only used for login and storing/retrieving JWT from local storage export interface User { - username: string; - token: string; - refreshToken: string; - roles: string[]; - preferences: Preferences; - apiKey: string; - email: string; - ageRestriction: AgeRestriction; + username: string; + token: string; + refreshToken: string; + roles: string[]; + preferences: Preferences; + apiKey: string; + email: string; + ageRestriction: AgeRestriction; + hasRunScrobbleEventGeneration: boolean; + scrobbleEventGenerationRan: string; // datetime } diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts index 381a38638..a01267cf3 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -6,9 +6,9 @@ export enum WikiLink { SeriesRelationships = 'https://wiki.kavitareader.com/guides/features/relationships', Bookmarks = 'https://wiki.kavitareader.com/guides/features/bookmarks', DataCollection = 'https://wiki.kavitareader.com/troubleshooting/faq#q-does-kavita-collect-any-data-on-me', - MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/media#media-issues', + MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/mediaissues/', KavitaPlusDiscordId = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+#discord-id', - KavitaPlus = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+', + KavitaPlus = 'https://wiki.kavitareader.com/kavita+', KavitaPlusFAQ = 'https://wiki.kavitareader.com/kavita+/faq', ReadingListCBL = 'https://wiki.kavitareader.com/guides/features/readinglists#creating-a-reading-list-via-cbl', Donation = 'https://wiki.kavitareader.com/donating', @@ -19,6 +19,7 @@ export enum WikiLink { Library = 'https://wiki.kavitareader.com/guides/admin-settings/libraries', UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native', UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker', - OpdsClients = 'https://wiki.kavitareader.com/guides/opds#opds-capable-clients', - Guides = 'https://wiki.kavitareader.com/guides' + OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients', + Guides = 'https://wiki.kavitareader.com/guides', + ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/", } diff --git a/UI/Web/src/app/_pipes/age-rating.pipe.ts b/UI/Web/src/app/_pipes/age-rating.pipe.ts index 15554cf05..f99a77f72 100644 --- a/UI/Web/src/app/_pipes/age-rating.pipe.ts +++ b/UI/Web/src/app/_pipes/age-rating.pipe.ts @@ -1,22 +1,22 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; -import { Observable, of } from 'rxjs'; -import { AgeRating } from '../_models/metadata/age-rating'; -import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; +import {AgeRating} from '../_models/metadata/age-rating'; +import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'ageRating', - standalone: true + standalone: true, + pure: true }) export class AgeRatingPipe implements PipeTransform { - translocoService = inject(TranslocoService); + private readonly translocoService = inject(TranslocoService); - transform(value: AgeRating | AgeRatingDto | undefined): Observable { - if (value === undefined || value === null) return of(this.translocoService.translate('age-rating-pipe.unknown') as string); + transform(value: AgeRating | AgeRatingDto | undefined): string { + if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown'); if (value.hasOwnProperty('title')) { - return of((value as AgeRatingDto).title); + return (value as AgeRatingDto).title; } switch (value) { @@ -54,7 +54,7 @@ export class AgeRatingPipe implements PipeTransform { return this.translocoService.translate('age-rating-pipe.r18-plus'); } - return of(this.translocoService.translate('age-rating-pipe.unknown') as string); + return this.translocoService.translate('age-rating-pipe.unknown'); } } diff --git a/UI/Web/src/app/_pipes/breakpoint.pipe.ts b/UI/Web/src/app/_pipes/breakpoint.pipe.ts new file mode 100644 index 000000000..1897b773c --- /dev/null +++ b/UI/Web/src/app/_pipes/breakpoint.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {UserBreakpoint} from "../shared/_services/utility.service"; + +@Pipe({ + name: 'breakpoint' +}) +export class BreakpointPipe implements PipeTransform { + + transform(value: UserBreakpoint): string { + const v = parseInt(value + '', 10) as UserBreakpoint; + switch (v) { + case UserBreakpoint.Never: + return translate('breakpoint-pipe.never'); + case UserBreakpoint.Mobile: + return translate('breakpoint-pipe.mobile'); + case UserBreakpoint.Tablet: + return translate('breakpoint-pipe.tablet'); + case UserBreakpoint.Desktop: + return translate('breakpoint-pipe.desktop'); + } + throw new Error("unknown breakpoint value: " + value); + } + +} diff --git a/UI/Web/src/app/_pipes/browse-title.pipe.ts b/UI/Web/src/app/_pipes/browse-title.pipe.ts new file mode 100644 index 000000000..0495e8b8a --- /dev/null +++ b/UI/Web/src/app/_pipes/browse-title.pipe.ts @@ -0,0 +1,78 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {FilterField} from "../_models/metadata/v2/filter-field"; +import {translate} from "@jsverse/transloco"; + +/** + * Responsible for taking a filter field and value (as a string) and translating into a "Browse X" heading for All Series page + * Example: Genre & "Action" -> Browse Action + * Example: Artist & "Joe Shmo" -> Browse Joe Shmo Works + */ +@Pipe({ + name: 'browseTitle' +}) +export class BrowseTitlePipe implements PipeTransform { + + transform(field: FilterField, value: string): string { + switch (field) { + case FilterField.PublicationStatus: + return translate('browse-title-pipe.publication-status', {value}); + case FilterField.AgeRating: + return translate('browse-title-pipe.age-rating', {value}); + case FilterField.UserRating: + return translate('browse-title-pipe.user-rating', {value}); + case FilterField.Tags: + return translate('browse-title-pipe.tag', {value}); + case FilterField.Translators: + return translate('browse-title-pipe.translator', {value}); + case FilterField.Characters: + return translate('browse-title-pipe.character', {value}); + case FilterField.Publisher: + return translate('browse-title-pipe.publisher', {value}); + case FilterField.Editor: + return translate('browse-title-pipe.editor', {value}); + case FilterField.CoverArtist: + return translate('browse-title-pipe.artist', {value}); + case FilterField.Letterer: + return translate('browse-title-pipe.letterer', {value}); + case FilterField.Colorist: + return translate('browse-title-pipe.colorist', {value}); + case FilterField.Inker: + return translate('browse-title-pipe.inker', {value}); + case FilterField.Penciller: + return translate('browse-title-pipe.penciller', {value}); + case FilterField.Writers: + return translate('browse-title-pipe.writer', {value}); + case FilterField.Genres: + return translate('browse-title-pipe.genre', {value}); + case FilterField.Libraries: + return translate('browse-title-pipe.library', {value}); + case FilterField.Formats: + return translate('browse-title-pipe.format', {value}); + case FilterField.ReleaseYear: + return translate('browse-title-pipe.release-year', {value}); + case FilterField.Imprint: + return translate('browse-title-pipe.imprint', {value}); + case FilterField.Team: + return translate('browse-title-pipe.team', {value}); + case FilterField.Location: + return translate('browse-title-pipe.location', {value}); + + // These have no natural links in the app to demand a richer title experience + case FilterField.Languages: + case FilterField.CollectionTags: + case FilterField.ReadProgress: + case FilterField.ReadTime: + case FilterField.Path: + case FilterField.FilePath: + case FilterField.WantToRead: + case FilterField.ReadingDate: + case FilterField.AverageRating: + case FilterField.ReadLast: + case FilterField.Summary: + case FilterField.SeriesName: + default: + return ''; + } + } + +} diff --git a/UI/Web/src/app/_pipes/confirm-translate.pipe.ts b/UI/Web/src/app/_pipes/confirm-translate.pipe.ts new file mode 100644 index 000000000..008f28849 --- /dev/null +++ b/UI/Web/src/app/_pipes/confirm-translate.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { translate } from '@jsverse/transloco'; + +@Pipe({ + name: 'confirmTranslate', + standalone: true +}) +export class ConfirmTranslatePipe implements PipeTransform { + + transform(value: string | undefined | null): string | undefined | null { + if (!value) return value; + + if (value.startsWith('confirm.')) { + return translate(value); + } + + return value; + } + +} diff --git a/UI/Web/src/app/_pipes/encode-format.pipe.ts b/UI/Web/src/app/_pipes/encode-format.pipe.ts index 9485f80f8..f082c0495 100644 --- a/UI/Web/src/app/_pipes/encode-format.pipe.ts +++ b/UI/Web/src/app/_pipes/encode-format.pipe.ts @@ -1,4 +1,4 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {EncodeFormat} from "../admin/_models/encode-format"; @Pipe({ diff --git a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts new file mode 100644 index 000000000..f342c0034 --- /dev/null +++ b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts @@ -0,0 +1,108 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {FilterField} from "../_models/metadata/v2/filter-field"; +import {translate} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; + +@Pipe({ + name: 'genericFilterField' +}) +export class GenericFilterFieldPipe implements PipeTransform { + + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case "series": + return this.translateFilterField(value as FilterField); + case "person": + return this.translatePersonFilterField(value as PersonFilterField); + } + } + + private translatePersonFilterField(value: PersonFilterField) { + switch (value) { + case PersonFilterField.Role: + return translate('generic-filter-field-pipe.person-role'); + case PersonFilterField.Name: + return translate('generic-filter-field-pipe.person-name'); + case PersonFilterField.SeriesCount: + return translate('generic-filter-field-pipe.person-series-count'); + case PersonFilterField.ChapterCount: + return translate('generic-filter-field-pipe.person-chapter-count'); + } + } + + private translateFilterField(value: FilterField) { + switch (value) { + case FilterField.AgeRating: + return translate('filter-field-pipe.age-rating'); + case FilterField.Characters: + return translate('filter-field-pipe.characters'); + case FilterField.CollectionTags: + return translate('filter-field-pipe.collection-tags'); + case FilterField.Colorist: + return translate('filter-field-pipe.colorist'); + case FilterField.CoverArtist: + return translate('filter-field-pipe.cover-artist'); + case FilterField.Editor: + return translate('filter-field-pipe.editor'); + case FilterField.Formats: + return translate('filter-field-pipe.formats'); + case FilterField.Genres: + return translate('filter-field-pipe.genres'); + case FilterField.Inker: + return translate('filter-field-pipe.inker'); + case FilterField.Imprint: + return translate('filter-field-pipe.imprint'); + case FilterField.Team: + return translate('filter-field-pipe.team'); + case FilterField.Location: + return translate('filter-field-pipe.location'); + case FilterField.Languages: + return translate('filter-field-pipe.languages'); + case FilterField.Libraries: + return translate('filter-field-pipe.libraries'); + case FilterField.Letterer: + return translate('filter-field-pipe.letterer'); + case FilterField.PublicationStatus: + return translate('filter-field-pipe.publication-status'); + case FilterField.Penciller: + return translate('filter-field-pipe.penciller'); + case FilterField.Publisher: + return translate('filter-field-pipe.publisher'); + case FilterField.ReadProgress: + return translate('filter-field-pipe.read-progress'); + case FilterField.ReadTime: + return translate('filter-field-pipe.read-time'); + case FilterField.ReleaseYear: + return translate('filter-field-pipe.release-year'); + case FilterField.SeriesName: + return translate('filter-field-pipe.series-name'); + case FilterField.Summary: + return translate('filter-field-pipe.summary'); + case FilterField.Tags: + return translate('filter-field-pipe.tags'); + case FilterField.Translators: + return translate('filter-field-pipe.translators'); + case FilterField.UserRating: + return translate('filter-field-pipe.user-rating'); + case FilterField.Writers: + return translate('filter-field-pipe.writers'); + case FilterField.Path: + return translate('filter-field-pipe.path'); + case FilterField.FilePath: + return translate('filter-field-pipe.file-path'); + case FilterField.WantToRead: + return translate('filter-field-pipe.want-to-read'); + case FilterField.ReadingDate: + return translate('filter-field-pipe.read-date'); + case FilterField.ReadLast: + return translate('filter-field-pipe.read-last'); + case FilterField.AverageRating: + return translate('filter-field-pipe.average-rating'); + default: + throw new Error(`Invalid FilterField value: ${value}`); + } + } + +} diff --git a/UI/Web/src/app/_pipes/library-name.pipe.ts b/UI/Web/src/app/_pipes/library-name.pipe.ts new file mode 100644 index 000000000..c34f50166 --- /dev/null +++ b/UI/Web/src/app/_pipes/library-name.pipe.ts @@ -0,0 +1,16 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {LibraryService} from "../_services/library.service"; +import {Observable} from "rxjs"; + +@Pipe({ + name: 'libraryName', + standalone: true +}) +export class LibraryNamePipe implements PipeTransform { + private readonly libraryService = inject(LibraryService); + + transform(libraryId: number): Observable { + return this.libraryService.getLibraryName(libraryId); + } + +} diff --git a/UI/Web/src/app/_pipes/library-type.pipe.ts b/UI/Web/src/app/_pipes/library-type.pipe.ts index 74a62647f..1881b64d5 100644 --- a/UI/Web/src/app/_pipes/library-type.pipe.ts +++ b/UI/Web/src/app/_pipes/library-type.pipe.ts @@ -11,7 +11,7 @@ import {TranslocoService} from "@jsverse/transloco"; }) export class LibraryTypePipe implements PipeTransform { - translocoService = inject(TranslocoService); + private readonly translocoService = inject(TranslocoService); transform(libraryType: LibraryType): string { switch (libraryType) { case LibraryType.Book: diff --git a/UI/Web/src/app/_pipes/log-level.pipe.ts b/UI/Web/src/app/_pipes/log-level.pipe.ts new file mode 100644 index 000000000..1a1c7c19a --- /dev/null +++ b/UI/Web/src/app/_pipes/log-level.pipe.ts @@ -0,0 +1,17 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from "@jsverse/transloco"; + +/** + * Transforms the log level string into a localized string + */ +@Pipe({ + name: 'logLevel', + standalone: true, + pure: true +}) +export class LogLevelPipe implements PipeTransform { + transform(value: string): string { + return translate('log-level-pipe.' + value.toLowerCase()); + } + +} diff --git a/UI/Web/src/app/_pipes/match-state.pipe.ts b/UI/Web/src/app/_pipes/match-state.pipe.ts new file mode 100644 index 000000000..9f0cb00ae --- /dev/null +++ b/UI/Web/src/app/_pipes/match-state.pipe.ts @@ -0,0 +1,27 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {MatchStateOption} from "../_models/kavitaplus/match-state-option"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'matchStateOption', + standalone: true +}) +export class MatchStateOptionPipe implements PipeTransform { + + transform(value: MatchStateOption): string { + switch (value) { + case MatchStateOption.DontMatch: + return translate('manage-matched-metadata.dont-match-label'); + case MatchStateOption.All: + return translate('manage-matched-metadata.all-status-label'); + case MatchStateOption.Matched: + return translate('manage-matched-metadata.matched-status-label'); + case MatchStateOption.NotMatched: + return translate('manage-matched-metadata.unmatched-status-label'); + case MatchStateOption.Error: + return translate('manage-matched-metadata.blacklist-status-label'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts b/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts new file mode 100644 index 000000000..dcaed4f69 --- /dev/null +++ b/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts @@ -0,0 +1,45 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {MetadataSettingField} from "../admin/_models/metadata-setting-field"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'metadataSettingFiled', + standalone: true +}) +export class MetadataSettingFiledPipe implements PipeTransform { + + transform(value: MetadataSettingField): string { + switch (value) { + case MetadataSettingField.ChapterTitle: + return translate('metadata-setting-field-pipe.chapter-title'); + case MetadataSettingField.ChapterSummary: + return translate('metadata-setting-field-pipe.chapter-summary'); + case MetadataSettingField.ChapterReleaseDate: + return translate('metadata-setting-field-pipe.chapter-release-date'); + case MetadataSettingField.ChapterPublisher: + return translate('metadata-setting-field-pipe.chapter-publisher'); + case MetadataSettingField.ChapterCovers: + return translate('metadata-setting-field-pipe.chapter-covers'); + case MetadataSettingField.AgeRating: + return translate('metadata-setting-field-pipe.age-rating'); + case MetadataSettingField.People: + return translate('metadata-setting-field-pipe.people'); + case MetadataSettingField.Covers: + return translate('metadata-setting-field-pipe.covers'); + case MetadataSettingField.Summary: + return translate('metadata-setting-field-pipe.summary'); + case MetadataSettingField.PublicationStatus: + return translate('metadata-setting-field-pipe.publication-status'); + case MetadataSettingField.StartDate: + return translate('metadata-setting-field-pipe.start-date'); + case MetadataSettingField.Genres: + return translate('metadata-setting-field-pipe.genres'); + case MetadataSettingField.Tags: + return translate('metadata-setting-field-pipe.tags'); + case MetadataSettingField.LocalizedName: + return translate('metadata-setting-field-pipe.localized-name'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/person-role.pipe.ts b/UI/Web/src/app/_pipes/person-role.pipe.ts index c1395ae5b..1b9ee2163 100644 --- a/UI/Web/src/app/_pipes/person-role.pipe.ts +++ b/UI/Web/src/app/_pipes/person-role.pipe.ts @@ -1,6 +1,6 @@ -import {inject, Pipe, PipeTransform} from '@angular/core'; -import { PersonRole } from '../_models/metadata/person'; -import {translate, TranslocoService} from "@jsverse/transloco"; +import {Pipe, PipeTransform} from '@angular/core'; +import {PersonRole} from '../_models/metadata/person'; +import {translate} from "@jsverse/transloco"; @Pipe({ name: 'personRole', @@ -10,8 +10,6 @@ export class PersonRolePipe implements PipeTransform { transform(value: PersonRole): string { switch (value) { - case PersonRole.Artist: - return translate('person-role-pipe.artist'); case PersonRole.Character: return translate('person-role-pipe.character'); case PersonRole.Colorist: diff --git a/UI/Web/src/app/_pipes/plus-media-format.pipe.ts b/UI/Web/src/app/_pipes/plus-media-format.pipe.ts new file mode 100644 index 000000000..b72822e33 --- /dev/null +++ b/UI/Web/src/app/_pipes/plus-media-format.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {PlusMediaFormat} from "../_models/series-detail/external-series-detail"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'plusMediaFormat', + standalone: true +}) +export class PlusMediaFormatPipe implements PipeTransform { + + transform(value: PlusMediaFormat): string { + switch (value) { + case PlusMediaFormat.Manga: + return translate('library-type-pipe.manga'); + case PlusMediaFormat.Comic: + return translate('library-type-pipe.comicVine'); + case PlusMediaFormat.LightNovel: + return translate('library-type-pipe.lightNovel'); + case PlusMediaFormat.Book: + return translate('library-type-pipe.book'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/provider-image.pipe.ts b/UI/Web/src/app/_pipes/provider-image.pipe.ts index 80574ef3b..5d845a672 100644 --- a/UI/Web/src/app/_pipes/provider-image.pipe.ts +++ b/UI/Web/src/app/_pipes/provider-image.pipe.ts @@ -17,6 +17,8 @@ export class ProviderImagePipe implements PipeTransform { return `assets/images/ExternalServices/GoogleBooks${large ? '-lg' : ''}.png`; case ScrobbleProvider.Kavita: return `assets/images/logo-${large ? '64' : '32'}.png`; + case ScrobbleProvider.Cbr: + return `assets/images/ExternalServices/ComicBookRoundup.png`; } } diff --git a/UI/Web/src/app/_pipes/provider-name.pipe.ts b/UI/Web/src/app/_pipes/provider-name.pipe.ts deleted file mode 100644 index 1947b3f5f..000000000 --- a/UI/Web/src/app/_pipes/provider-name.pipe.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import {ScrobbleProvider} from "../_services/scrobbling.service"; - -@Pipe({ - name: 'providerName', - standalone: true -}) -export class ProviderNamePipe implements PipeTransform { - - transform(value: ScrobbleProvider): string { - switch (value) { - case ScrobbleProvider.AniList: - return 'AniList'; - case ScrobbleProvider.Mal: - return 'MAL'; - case ScrobbleProvider.Kavita: - return 'Kavita'; - case ScrobbleProvider.GoogleBooks: - return 'Google Books'; - } - } - -} diff --git a/UI/Web/src/app/_pipes/role-localized.pipe.ts b/UI/Web/src/app/_pipes/role-localized.pipe.ts new file mode 100644 index 000000000..1890962dd --- /dev/null +++ b/UI/Web/src/app/_pipes/role-localized.pipe.ts @@ -0,0 +1,15 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {Role} from "../_services/account.service"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'roleLocalized' +}) +export class RoleLocalizedPipe implements PipeTransform { + + transform(value: Role | string): string { + const key = (value + '').toLowerCase().replace(' ', '-'); + return translate(`role-localized-pipe.${key}`); + } + +} diff --git a/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts index 7617f04ec..cc6e01449 100644 --- a/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts +++ b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts @@ -12,6 +12,7 @@ export class ScrobbleProviderNamePipe implements PipeTransform { case ScrobbleProvider.AniList: return 'AniList'; case ScrobbleProvider.Mal: return 'MAL'; case ScrobbleProvider.Kavita: return 'Kavita'; + case ScrobbleProvider.Cbr: return 'Comicbook Roundup'; case ScrobbleProvider.GoogleBooks: return 'Google Books'; } } diff --git a/UI/Web/src/app/_pipes/sort-field.pipe.ts b/UI/Web/src/app/_pipes/sort-field.pipe.ts index 13ff4f758..d032de9c8 100644 --- a/UI/Web/src/app/_pipes/sort-field.pipe.ts +++ b/UI/Web/src/app/_pipes/sort-field.pipe.ts @@ -1,6 +1,8 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {SortField} from "../_models/metadata/series-filter"; import {TranslocoService} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Pipe({ name: 'sortField', @@ -11,7 +13,30 @@ export class SortFieldPipe implements PipeTransform { constructor(private translocoService: TranslocoService) { } - transform(value: SortField): string { + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case 'series': + return this.seriesSortFields(value as SortField); + case 'person': + return this.personSortFields(value as PersonSortField); + + } + } + + private personSortFields(value: PersonSortField) { + switch (value) { + case PersonSortField.Name: + return this.translocoService.translate('sort-field-pipe.person-name'); + case PersonSortField.SeriesCount: + return this.translocoService.translate('sort-field-pipe.person-series-count'); + case PersonSortField.ChapterCount: + return this.translocoService.translate('sort-field-pipe.person-chapter-count'); + + } + } + + private seriesSortFields(value: SortField) { switch (value) { case SortField.SortName: return this.translocoService.translate('sort-field-pipe.sort-name'); @@ -32,7 +57,6 @@ export class SortFieldPipe implements PipeTransform { case SortField.Random: return this.translocoService.translate('sort-field-pipe.random'); } - } } diff --git a/UI/Web/src/app/_pipes/time-ago.pipe.ts b/UI/Web/src/app/_pipes/time-ago.pipe.ts index 99039126c..9940d4bb7 100644 --- a/UI/Web/src/app/_pipes/time-ago.pipe.ts +++ b/UI/Web/src/app/_pipes/time-ago.pipe.ts @@ -39,9 +39,8 @@ export class TimeAgoPipe implements PipeTransform, OnDestroy { constructor(private readonly changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, private translocoService: TranslocoService) {} - transform(value: string) { - - if (value === '' || value === null || value === undefined || value.split('T')[0] === '0001-01-01') { + transform(value: string | Date | null) { + if (value === '' || value === null || value === undefined || (typeof value === 'string' && value.split('T')[0] === '0001-01-01')) { return this.translocoService.translate('time-ago-pipe.never'); } diff --git a/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts b/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts new file mode 100644 index 000000000..0a25eefdc --- /dev/null +++ b/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {DateTime} from "luxon"; + +@Pipe({ + name: 'utcToLocaleDate', + standalone: true +}) +/** + * This is the same as the UtcToLocalTimePipe but returning a timezone aware DateTime object rather than a string. + * Use this when the next operation needs a Date object (like the TimeAgoPipe) + */ +export class UtcToLocaleDatePipe implements PipeTransform { + + transform(utcDate: string | undefined | null): Date | null { + if (utcDate === '' || utcDate === null || utcDate === undefined || utcDate.split('T')[0] === '0001-01-01') { + return null; + } + + const browserLanguage = navigator.language; + const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage); + return dateTime.toJSDate() + } + +} diff --git a/UI/Web/src/app/_resolvers/reading-profile.resolver.ts b/UI/Web/src/app/_resolvers/reading-profile.resolver.ts new file mode 100644 index 000000000..1d28adf95 --- /dev/null +++ b/UI/Web/src/app/_resolvers/reading-profile.resolver.ts @@ -0,0 +1,18 @@ +import {Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router'; +import {Observable} from 'rxjs'; +import {ReadingProfileService} from "../_services/reading-profile.service"; + +@Injectable({ + providedIn: 'root' +}) +export class ReadingProfileResolver implements Resolve { + + constructor(private readingProfileService: ReadingProfileService) {} + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + // Extract seriesId from route params or parent route + const seriesId = route.params['seriesId'] || route.parent?.params['seriesId']; + return this.readingProfileService.getForSeries(seriesId); + } +} diff --git a/UI/Web/src/app/_resolvers/url-filter.resolver.ts b/UI/Web/src/app/_resolvers/url-filter.resolver.ts new file mode 100644 index 000000000..16bc5c752 --- /dev/null +++ b/UI/Web/src/app/_resolvers/url-filter.resolver.ts @@ -0,0 +1,22 @@ +import {Injectable} from "@angular/core"; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from "@angular/router"; +import {Observable, of} from "rxjs"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; + +/** + * Checks the url for a filter and resolves one if applicable, otherwise returns null. + * It is up to the consumer to cast appropriately. + */ +@Injectable({ + providedIn: 'root' +}) +export class UrlFilterResolver implements Resolve { + + constructor(private filterUtilitiesService: FilterUtilitiesService) {} + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + if (!state.url.includes('?')) return of(null); + return this.filterUtilitiesService.decodeFilter(state.url.split('?')[1]); + } +} diff --git a/UI/Web/src/app/_routes/all-series-routing.module.ts b/UI/Web/src/app/_routes/all-series-routing.module.ts index d9dfaaf96..5c4804251 100644 --- a/UI/Web/src/app/_routes/all-series-routing.module.ts +++ b/UI/Web/src/app/_routes/all-series-routing.module.ts @@ -1,7 +1,13 @@ -import { Routes } from "@angular/router"; -import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; +import {Routes} from "@angular/router"; +import {AllSeriesComponent} from "../all-series/_components/all-series/all-series.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: AllSeriesComponent, pathMatch: 'full'}, + {path: '', component: AllSeriesComponent, pathMatch: 'full', + runGuardsAndResolvers: 'always', + resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_routes/book-reader.router.module.ts b/UI/Web/src/app/_routes/book-reader.router.module.ts index 5083c2d4a..c9d6262ad 100644 --- a/UI/Web/src/app/_routes/book-reader.router.module.ts +++ b/UI/Web/src/app/_routes/book-reader.router.module.ts @@ -1,10 +1,14 @@ -import { Routes } from '@angular/router'; -import { BookReaderComponent } from '../book-reader/_components/book-reader/book-reader.component'; +import {Routes} from '@angular/router'; +import {BookReaderComponent} from '../book-reader/_components/book-reader/book-reader.component'; +import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver"; export const routes: Routes = [ { path: ':chapterId', component: BookReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } } ]; diff --git a/UI/Web/src/app/_routes/bookmark-routing.module.ts b/UI/Web/src/app/_routes/bookmark-routing.module.ts index 6da971e08..2c7c52036 100644 --- a/UI/Web/src/app/_routes/bookmark-routing.module.ts +++ b/UI/Web/src/app/_routes/bookmark-routing.module.ts @@ -1,6 +1,12 @@ -import { Routes } from "@angular/router"; -import { BookmarksComponent } from "../bookmark/_components/bookmarks/bookmarks.component"; +import {Routes} from "@angular/router"; +import {BookmarksComponent} from "../bookmark/_components/bookmarks/bookmarks.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: BookmarksComponent, pathMatch: 'full'}, + {path: '', component: BookmarksComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/browse-authors-routing.module.ts b/UI/Web/src/app/_routes/browse-authors-routing.module.ts deleted file mode 100644 index e7aab1b57..000000000 --- a/UI/Web/src/app/_routes/browse-authors-routing.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Routes } from "@angular/router"; -import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; -import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component"; - - -export const routes: Routes = [ - {path: '', component: BrowseAuthorsComponent, pathMatch: 'full'}, -]; diff --git a/UI/Web/src/app/_routes/browse-routing.module.ts b/UI/Web/src/app/_routes/browse-routing.module.ts new file mode 100644 index 000000000..be96e8193 --- /dev/null +++ b/UI/Web/src/app/_routes/browse-routing.module.ts @@ -0,0 +1,24 @@ +import {Routes} from "@angular/router"; +import {BrowsePeopleComponent} from "../browse/browse-people/browse-people.component"; +import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component"; +import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; + + +export const routes: Routes = [ + // Legacy route + {path: 'authors', component: BrowsePeopleComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, + {path: 'people', component: BrowsePeopleComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, + {path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'}, + {path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'}, +]; diff --git a/UI/Web/src/app/_routes/collections-routing.module.ts b/UI/Web/src/app/_routes/collections-routing.module.ts index 80510c8f6..2b3b0ffd7 100644 --- a/UI/Web/src/app/_routes/collections-routing.module.ts +++ b/UI/Web/src/app/_routes/collections-routing.module.ts @@ -1,9 +1,15 @@ -import { Routes } from '@angular/router'; -import { AllCollectionsComponent } from '../collections/_components/all-collections/all-collections.component'; -import { CollectionDetailComponent } from '../collections/_components/collection-detail/collection-detail.component'; +import {Routes} from '@angular/router'; +import {AllCollectionsComponent} from '../collections/_components/all-collections/all-collections.component'; +import {CollectionDetailComponent} from '../collections/_components/collection-detail/collection-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ {path: '', component: AllCollectionsComponent, pathMatch: 'full'}, - {path: ':id', component: CollectionDetailComponent}, + {path: ':id', component: CollectionDetailComponent, + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/library-detail-routing.module.ts b/UI/Web/src/app/_routes/library-detail-routing.module.ts index 04cb3c9dd..3c09a71ee 100644 --- a/UI/Web/src/app/_routes/library-detail-routing.module.ts +++ b/UI/Web/src/app/_routes/library-detail-routing.module.ts @@ -1,7 +1,8 @@ -import { Routes } from '@angular/router'; -import { AuthGuard } from '../_guards/auth.guard'; -import { LibraryAccessGuard } from '../_guards/library-access.guard'; -import { LibraryDetailComponent } from '../library-detail/library-detail.component'; +import {Routes} from '@angular/router'; +import {AuthGuard} from '../_guards/auth.guard'; +import {LibraryAccessGuard} from '../_guards/library-access.guard'; +import {LibraryDetailComponent} from '../library-detail/library-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ @@ -9,12 +10,18 @@ export const routes: Routes = [ path: ':libraryId', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, }, { path: '', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent - } + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, + }, ]; diff --git a/UI/Web/src/app/_routes/manga-reader.router.module.ts b/UI/Web/src/app/_routes/manga-reader.router.module.ts index 04ff77b3c..e479e8ae6 100644 --- a/UI/Web/src/app/_routes/manga-reader.router.module.ts +++ b/UI/Web/src/app/_routes/manga-reader.router.module.ts @@ -1,15 +1,22 @@ -import { Routes } from '@angular/router'; -import { MangaReaderComponent } from '../manga-reader/_components/manga-reader/manga-reader.component'; +import {Routes} from '@angular/router'; +import {MangaReaderComponent} from '../manga-reader/_components/manga-reader/manga-reader.component'; +import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver"; export const routes: Routes = [ { path: ':chapterId', - component: MangaReaderComponent + component: MangaReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } }, { // This will allow the MangaReader to have a list to use for next/prev chapters rather than natural sort order path: ':chapterId/list/:listId', - component: MangaReaderComponent + component: MangaReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } } ]; diff --git a/UI/Web/src/app/_routes/pdf-reader.router.module.ts b/UI/Web/src/app/_routes/pdf-reader.router.module.ts index a55699280..7cb9f68e2 100644 --- a/UI/Web/src/app/_routes/pdf-reader.router.module.ts +++ b/UI/Web/src/app/_routes/pdf-reader.router.module.ts @@ -1,9 +1,13 @@ -import { Routes } from '@angular/router'; -import { PdfReaderComponent } from '../pdf-reader/_components/pdf-reader/pdf-reader.component'; +import {Routes} from '@angular/router'; +import {PdfReaderComponent} from '../pdf-reader/_components/pdf-reader/pdf-reader.component'; +import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver"; export const routes: Routes = [ { path: ':chapterId', component: PdfReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } } ]; diff --git a/UI/Web/src/app/_routes/want-to-read-routing.module.ts b/UI/Web/src/app/_routes/want-to-read-routing.module.ts index b3301d9f9..b593172c0 100644 --- a/UI/Web/src/app/_routes/want-to-read-routing.module.ts +++ b/UI/Web/src/app/_routes/want-to-read-routing.module.ts @@ -1,6 +1,10 @@ -import { Routes } from '@angular/router'; -import { WantToReadComponent } from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {Routes} from '@angular/router'; +import {WantToReadComponent} from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: WantToReadComponent, pathMatch: 'full'}, + {path: '', component: WantToReadComponent, pathMatch: 'full', runGuardsAndResolvers: 'always', resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 54b54931f..f1f91143f 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,20 +1,22 @@ -import { HttpClient } from '@angular/common/http'; -import {DestroyRef, inject, Injectable } from '@angular/core'; -import {catchError, Observable, of, ReplaySubject, shareReplay, throwError} from 'rxjs'; +import {HttpClient} from '@angular/common/http'; +import {DestroyRef, inject, Injectable} from '@angular/core'; +import {Observable, of, ReplaySubject, shareReplay} from 'rxjs'; import {filter, map, switchMap, tap} from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { Preferences } from '../_models/preferences/preferences'; -import { User } from '../_models/user'; -import { Router } from '@angular/router'; -import { EVENTS, MessageHubService } from './message-hub.service'; -import { ThemeService } from './theme.service'; -import { InviteUserResponse } from '../_models/auth/invite-user-response'; -import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { AgeRating } from '../_models/metadata/age-rating'; -import { AgeRestriction } from '../_models/metadata/age-restriction'; -import { TextResonse } from '../_types/text-response'; +import {environment} from 'src/environments/environment'; +import {Preferences} from '../_models/preferences/preferences'; +import {User} from '../_models/user'; +import {Router} from '@angular/router'; +import {EVENTS, MessageHubService} from './message-hub.service'; +import {ThemeService} from './theme.service'; +import {InviteUserResponse} from '../_models/auth/invite-user-response'; +import {UserUpdateEvent} from '../_models/events/user-update-event'; +import {AgeRating} from '../_models/metadata/age-rating'; +import {AgeRestriction} from '../_models/metadata/age-restriction'; +import {TextResonse} from '../_types/text-response'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {Action} from "./action-factory.service"; +import {LicenseService} from "./license.service"; +import {LocalizationService} from "./localization.service"; export enum Role { Admin = 'Admin', @@ -27,12 +29,25 @@ export enum Role { Promote = 'Promote', } +export const allRoles = [ + Role.Admin, + Role.ChangePassword, + Role.Bookmark, + Role.Download, + Role.ChangeRestriction, + Role.ReadOnly, + Role.Login, + Role.Promote, +] + @Injectable({ providedIn: 'root' }) export class AccountService { private readonly destroyRef = inject(DestroyRef); + private readonly licenseService = inject(LicenseService); + private readonly localizationService = inject(LocalizationService); baseUrl = environment.apiUrl; userKey = 'kavita-user'; @@ -42,17 +57,13 @@ export class AccountService { // Stores values, when someone subscribes gives (1) of last values seen. private currentUserSource = new ReplaySubject(1); - public currentUser$ = this.currentUserSource.asObservable(); + public currentUser$ = this.currentUserSource.asObservable().pipe(takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); public isAdmin$: Observable = this.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => { if (!u) return false; return this.hasAdminRole(u); }), shareReplay({bufferSize: 1, refCount: true})); - private hasValidLicenseSource = new ReplaySubject(1); - /** - * Does the user have an active license - */ - public hasValidLicense$ = this.hasValidLicenseSource.asObservable(); + /** * SetTimeout handler for keeping track of refresh token call @@ -91,17 +102,63 @@ export class AccountService { return true; } - hasAnyRole(user: User, roles: Array) { + /** + * If the user has any role in the restricted roles array or is an Admin + * @param user + * @param roles + * @param restrictedRoles + */ + hasAnyRole(user: User, roles: Array, restrictedRoles: Array = []) { if (!user || !user.roles) { return false; } + + // If the user is an admin, they have the role + if (this.hasAdminRole(user)) { + return true; + } + + // If restricted roles are provided and the user has any of them, deny access + if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { + return false; + } + + // If roles are empty, allow access (no restrictions by roles) if (roles.length === 0) { return true; } + // Allow access if the user has any of the allowed roles return roles.some(role => user.roles.includes(role)); } + /** + * If User or Admin, will return false + * @param user + * @param restrictedRoles + */ + hasAnyRestrictedRole(user: User, restrictedRoles: Array = []) { + if (!user || !user.roles) { + return true; + } + + if (restrictedRoles.length === 0) { + return false; + } + + // If the user is an admin, they have the role + if (this.hasAdminRole(user)) { + return false; + } + + + if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { + return true; + } + + return false; + } + hasAdminRole(user: User) { return user && user.roles.includes(Role.Admin); } @@ -111,7 +168,7 @@ export class AccountService { } hasChangeAgeRestrictionRole(user: User) { - return user && user.roles.includes(Role.ChangeRestriction); + return user && !user.roles.includes(Role.Admin) && user.roles.includes(Role.ChangeRestriction); } hasDownloadRole(user: User) { @@ -134,40 +191,7 @@ export class AccountService { return this.httpClient.get(this.baseUrl + 'account/roles'); } - deleteLicense() { - return this.httpClient.delete(this.baseUrl + 'license', TextResonse); - } - resetLicense(license: string, email: string) { - return this.httpClient.post(this.baseUrl + 'license/reset', {license, email}, TextResonse); - } - - hasValidLicense(forceCheck: boolean = false) { - console.log('hasValidLicense being called: ', forceCheck); - return this.httpClient.get(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse) - .pipe( - map(res => res === "true"), - tap(res => { - this.hasValidLicenseSource.next(res) - }), - catchError(error => { - this.hasValidLicenseSource.next(false); - return throwError(error); // Rethrow the error to propagate it further - }) - ); - } - - hasAnyLicense() { - return this.httpClient.get(this.baseUrl + 'license/has-license', TextResonse) - .pipe( - map(res => res === "true"), - ); - } - - updateUserLicense(license: string, email: string, discordId?: string) { - return this.httpClient.post(this.baseUrl + 'license', {license, email, discordId}, TextResonse) - .pipe(map(res => res === "true")); - } login(model: {username: string, password: string, apiKey?: string}) { return this.httpClient.post(this.baseUrl + 'account/login', model).pipe( @@ -182,6 +206,8 @@ export class AccountService { } setCurrentUser(user?: User, refreshConnections = true) { + + const isSameUser = this.currentUser === user; if (user) { user.roles = []; const roles = this.getDecodedToken(user.token).role; @@ -209,9 +235,11 @@ export class AccountService { if (this.currentUser) { // BUG: StopHubConnection has a promise in it, this needs to be async // But that really messes everything up - this.messageHub.stopHubConnection(); - this.messageHub.createHubConnection(this.currentUser); - this.hasValidLicense().subscribe(); + if (!isSameUser) { + this.messageHub.stopHubConnection(); + this.messageHub.createHubConnection(this.currentUser); + this.licenseService.hasValidLicense().subscribe(); + } this.startRefreshTokenTimer(); } } @@ -330,6 +358,8 @@ export class AccountService { // Update the locale on disk (for logout and compact-number pipe) localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale); + this.localizationService.refreshTranslations(this.currentUser.preferences.locale); + } return settings; }), takeUntilDestroyed(this.destroyRef)); diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 24ca2a76a..e5967bf24 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -1,18 +1,19 @@ -import { Injectable } from '@angular/core'; -import { map, Observable, shareReplay } from 'rxjs'; -import { Chapter } from '../_models/chapter'; +import {Injectable} from '@angular/core'; +import {map, Observable, shareReplay} from 'rxjs'; +import {Chapter} from '../_models/chapter'; import {UserCollection} from '../_models/collection-tag'; -import { Device } from '../_models/device/device'; -import { Library } from '../_models/library/library'; -import { ReadingList } from '../_models/reading-list'; -import { Series } from '../_models/series'; -import { Volume } from '../_models/volume'; -import { AccountService } from './account.service'; -import { DeviceService } from './device.service'; +import {Device} from '../_models/device/device'; +import {Library} from '../_models/library/library'; +import {ReadingList} from '../_models/reading-list'; +import {Series} from '../_models/series'; +import {Volume} from '../_models/volume'; +import {AccountService, Role} from './account.service'; +import {DeviceService} from './device.service'; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {translate} from "@jsverse/transloco"; import {Person} from "../_models/metadata/person"; +import {User} from '../_models/user'; export enum Action { Submenu = -1, @@ -106,26 +107,49 @@ export enum Action { Promote = 24, UnPromote = 25, /** - * Invoke a refresh covers as false to generate colorscapes + * Invoke refresh covers as false to generate colorscapes */ GenerateColorScape = 26, /** * Copy settings from one entity to another */ - CopySettings = 27 + CopySettings = 27, + /** + * Match an entity with an upstream system + */ + Match = 28, + /** + * Merge two (or more?) entities + */ + Merge = 29, + /** + * Add to a reading profile + */ + SetReadingProfile = 30, + /** + * Remove the reading profile from the entity + */ + ClearReadingProfile = 31, } /** * Callback for an action */ -export type ActionCallback = (action: ActionItem, data: T) => void; -export type ActionAllowedCallback = (action: ActionItem) => boolean; +export type ActionCallback = (action: ActionItem, entity: T) => void; +export type ActionShouldRenderFunc = (action: ActionItem, entity: T, user: User) => boolean; export interface ActionItem { title: string; description: string; action: Action; callback: ActionCallback; + /** + * Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply. + */ + requiredRoles: Role[]; + /** + * @deprecated Use required Roles instead + */ requiresAdmin: boolean; children: Array>; /** @@ -141,88 +165,98 @@ export interface ActionItem { * Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return */ _extra?: {title: string, data: any}; + /** + * Will call on each action to determine if it should show for the appropriate entity based on state and user + */ + shouldRender: ActionShouldRenderFunc; } +/** + * Entities that can be actioned upon + */ +export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | null; + @Injectable({ providedIn: 'root', }) export class ActionFactoryService { - libraryActions: Array> = []; - - seriesActions: Array> = []; - - volumeActions: Array> = []; - - chapterActions: Array> = []; - - collectionTagActions: Array> = []; - - readingListActions: Array> = []; - - bookmarkActions: Array> = []; - + private libraryActions: Array> = []; + private seriesActions: Array> = []; + private volumeActions: Array> = []; + private chapterActions: Array> = []; + private collectionTagActions: Array> = []; + private readingListActions: Array> = []; + private bookmarkActions: Array> = []; private personActions: Array> = []; - - sideNavStreamActions: Array> = []; - smartFilterActions: Array> = []; - - isAdmin = false; - + private sideNavStreamActions: Array> = []; + private smartFilterActions: Array> = []; + private sideNavHomeActions: Array> = []; constructor(private accountService: AccountService, private deviceService: DeviceService) { - this.accountService.currentUser$.subscribe((user) => { - if (user) { - this.isAdmin = this.accountService.hasAdminRole(user); - } else { - this._resetActions(); - return; // If user is logged out, we don't need to do anything - } - + this.accountService.currentUser$.subscribe((_) => { this._resetActions(); }); } - getLibraryActions(callback: ActionCallback) { - return this.applyCallbackToList(this.libraryActions, callback); + getLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem[]; } - getSeriesActions(callback: ActionCallback) { - return this.applyCallbackToList(this.seriesActions, callback); + getSeriesActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc); } - getSideNavStreamActions(callback: ActionCallback) { - return this.applyCallbackToList(this.sideNavStreamActions, callback); + getSideNavStreamActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc); } - getSmartFilterActions(callback: ActionCallback) { - return this.applyCallbackToList(this.smartFilterActions, callback); + getSmartFilterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc); } - getVolumeActions(callback: ActionCallback) { - return this.applyCallbackToList(this.volumeActions, callback); + getVolumeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc); } - getChapterActions(callback: ActionCallback) { - return this.applyCallbackToList(this.chapterActions, callback); + getChapterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc); } - getCollectionTagActions(callback: ActionCallback) { - return this.applyCallbackToList(this.collectionTagActions, callback); + getCollectionTagActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc); } - getReadingListActions(callback: ActionCallback) { - return this.applyCallbackToList(this.readingListActions, callback); + getReadingListActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc); } - getBookmarkActions(callback: ActionCallback) { - return this.applyCallbackToList(this.bookmarkActions, callback); + getBookmarkActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc); } - getPersonActions(callback: ActionCallback) { - return this.applyCallbackToList(this.personActions, callback); + getPersonActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc); } - dummyCallback(action: ActionItem, data: any) {} + getSideNavHomeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc); + } + + dummyCallback(action: ActionItem, entity: any) {} + dummyShouldRender(action: ActionItem, entity: any, user: User) {return true;} + basicReadRender(action: ActionItem, entity: any, user: User) { + if (entity === null || entity === undefined) return true; + if (!entity.hasOwnProperty('pagesRead') && !entity.hasOwnProperty('pages')) return true; + + switch (action.action) { + case(Action.MarkAsRead): + return entity.pagesRead < entity.pages; + case(Action.MarkAsUnread): + return entity.pagesRead !== 0; + default: + return true; + } + } filterSendToAction(actions: Array>, chapter: Chapter) { // if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) { @@ -265,7 +299,7 @@ export class ActionFactoryService { return tasks.filter(t => !blacklist.includes(t.action)); } - getBulkLibraryActions(callback: ActionCallback) { + getBulkLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { // Scan is currently not supported due to the backend not being able to handle it yet const actions = this.flattenActions(this.libraryActions).filter(a => { @@ -279,11 +313,13 @@ export class ActionFactoryService { dynamicList: undefined, action: Action.CopySettings, callback: this.dummyCallback, + shouldRender: shouldRenderFunc, children: [], + requiredRoles: [Role.Admin], requiresAdmin: true, title: 'copy-settings' }) - return this.applyCallbackToList(actions, callback); + return this.applyCallbackToList(actions, callback, shouldRenderFunc) as ActionItem[]; } flattenActions(actions: Array>): Array> { @@ -309,22 +345,59 @@ export class ActionFactoryService { title: 'scan-library', description: 'scan-library-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, + { + action: Action.Submenu, + title: 'reading-profiles', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [ + { + action: Action.SetReadingProfile, + title: 'set-reading-profile', + description: 'set-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + { + action: Action.ClearReadingProfile, + title: 'clear-reading-profile', + description: 'clear-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + ], + }, { action: Action.Submenu, title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -332,7 +405,9 @@ export class ActionFactoryService { title: 'generate-colorscape', description: 'generate-colorscape-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -340,7 +415,9 @@ export class ActionFactoryService { title: 'analyze-files', description: 'analyze-files-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -348,7 +425,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ], @@ -358,7 +437,9 @@ export class ActionFactoryService { title: 'settings', description: 'settings-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ]; @@ -369,7 +450,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -377,7 +460,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], }, @@ -386,7 +471,9 @@ export class ActionFactoryService { title: 'promote', description: 'promote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -394,7 +481,9 @@ export class ActionFactoryService { title: 'unpromote', description: 'unpromote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -405,7 +494,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -413,7 +504,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -421,7 +514,9 @@ export class ActionFactoryService { title: 'scan-series', description: 'scan-series-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -429,14 +524,18 @@ export class ActionFactoryService { title: 'add-to', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToWantToReadList, title: 'add-to-want-to-read', description: 'add-to-want-to-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -444,7 +543,9 @@ export class ActionFactoryService { title: 'remove-from-want-to-read', description: 'remove-to-want-to-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -452,7 +553,9 @@ export class ActionFactoryService { title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -460,25 +563,11 @@ export class ActionFactoryService { title: 'add-to-collection', description: 'add-to-collection-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], - }, - // { - // action: Action.AddToScrobbleHold, - // title: 'add-to-scrobble-hold', - // description: 'add-to-scrobble-hold-tooltip', - // callback: this.dummyCallback, - // requiresAdmin: true, - // children: [], - // }, - // { - // action: Action.RemoveFromScrobbleHold, - // title: 'remove-from-scrobble-hold', - // description: 'remove-from-scrobble-hold-tooltip', - // callback: this.dummyCallback, - // requiresAdmin: true, - // children: [], - // }, + } ], }, { @@ -486,14 +575,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -501,19 +594,54 @@ export class ActionFactoryService { } ], }, + { + action: Action.Submenu, + title: 'reading-profiles', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [ + { + action: Action.SetReadingProfile, + title: 'set-reading-profile', + description: 'set-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + { + action: Action.ClearReadingProfile, + title: 'clear-reading-profile', + description: 'clear-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + ], + }, { action: Action.Submenu, title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -521,7 +649,9 @@ export class ActionFactoryService { title: 'generate-colorscape', description: 'generate-colorscape-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -529,7 +659,9 @@ export class ActionFactoryService { title: 'analyze-files', description: 'analyze-files-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -537,18 +669,32 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], class: 'danger', children: [], }, ], }, + { + action: Action.Match, + title: 'match', + description: 'match-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: true, + requiredRoles: [Role.Admin], + children: [], + }, { action: Action.Download, title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, { @@ -556,7 +702,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ]; @@ -567,7 +715,9 @@ export class ActionFactoryService { title: 'read-incognito', description: 'read-incognito-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -575,7 +725,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -583,7 +735,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -591,14 +745,18 @@ export class ActionFactoryService { title: 'add-to', description: '=', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -608,14 +766,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -628,14 +790,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -643,7 +809,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ] @@ -653,7 +821,9 @@ export class ActionFactoryService { title: 'details', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -664,7 +834,9 @@ export class ActionFactoryService { title: 'read-incognito', description: 'read-incognito-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -672,7 +844,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -680,7 +854,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -688,14 +864,18 @@ export class ActionFactoryService { title: 'add-to', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -705,14 +885,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -726,14 +910,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -741,7 +929,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, ] @@ -751,7 +941,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -762,7 +954,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -770,7 +964,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], }, @@ -779,7 +975,9 @@ export class ActionFactoryService { title: 'promote', description: 'promote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -787,7 +985,9 @@ export class ActionFactoryService { title: 'unpromote', description: 'unpromote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -798,7 +998,19 @@ export class ActionFactoryService { title: 'edit', description: 'edit-person-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], + children: [], + }, + { + action: Action.Merge, + title: 'merge', + description: 'merge-person-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], } ]; @@ -809,7 +1021,9 @@ export class ActionFactoryService { title: 'view-series', description: 'view-series-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -817,7 +1031,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -825,8 +1041,10 @@ export class ActionFactoryService { title: 'clear', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, class: 'danger', requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -837,7 +1055,9 @@ export class ActionFactoryService { title: 'mark-visible', description: 'mark-visible-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -845,38 +1065,76 @@ export class ActionFactoryService { title: 'mark-invisible', description: 'mark-invisible-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; this.smartFilterActions = [ + { + action: Action.Edit, + title: 'rename', + description: 'rename-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; + + this.sideNavHomeActions = [ + { + action: Action.Edit, + title: 'reorder', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + } + ] + + } - private applyCallback(action: ActionItem, callback: (action: ActionItem, data: any) => void) { + private applyCallback(action: ActionItem, callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc) { action.callback = callback; + action.shouldRender = shouldRenderFunc; if (action.children === null || action.children?.length === 0) return; - action.children?.forEach((childAction) => { - this.applyCallback(childAction, callback); + // Ensure action children are a copy of the parent (since parent does a shallow mapping) + action.children = action.children.map(d => { return {...d}; }); + + action.children.forEach((childAction) => { + this.applyCallback(childAction, callback, shouldRenderFunc); }); } - public applyCallbackToList(list: Array>, callback: (action: ActionItem, data: any) => void): Array> { + public applyCallbackToList(list: Array>, + callback: ActionCallback, + shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender): Array> { + // Create a clone of the list to ensure we aren't affecting the default state const actions = list.map((a) => { return { ...a }; }); - actions.forEach((action) => this.applyCallback(action, callback)); + + actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc)); + return actions; } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 27e38f82f..2328bf72e 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -1,23 +1,27 @@ import {inject, Injectable} from '@angular/core'; -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; -import { ToastrService } from 'ngx-toastr'; -import { take } from 'rxjs/operators'; -import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; -import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; -import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; -import { ConfirmService } from '../shared/confirm.service'; -import { LibrarySettingsModalComponent } from '../sidenav/_modals/library-settings-modal/library-settings-modal.component'; -import { Chapter } from '../_models/chapter'; -import { Device } from '../_models/device/device'; -import { Library } from '../_models/library/library'; -import { ReadingList } from '../_models/reading-list'; -import { Series } from '../_models/series'; -import { Volume } from '../_models/volume'; -import { DeviceService } from './device.service'; -import { LibraryService } from './library.service'; -import { MemberService } from './member.service'; -import { ReaderService } from './reader.service'; -import { SeriesService } from './series.service'; +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {ToastrService} from 'ngx-toastr'; +import {take} from 'rxjs/operators'; +import {BulkAddToCollectionComponent} from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; +import {ADD_FLOW, AddToListModalComponent} from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; +import { + EditReadingListModalComponent +} from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; +import {ConfirmService} from '../shared/confirm.service'; +import { + LibrarySettingsModalComponent +} from '../sidenav/_modals/library-settings-modal/library-settings-modal.component'; +import {Chapter} from '../_models/chapter'; +import {Device} from '../_models/device/device'; +import {Library} from '../_models/library/library'; +import {ReadingList} from '../_models/reading-list'; +import {Series} from '../_models/series'; +import {Volume} from '../_models/volume'; +import {DeviceService} from './device.service'; +import {LibraryService} from './library.service'; +import {MemberService} from './member.service'; +import {ReaderService} from './reader.service'; +import {SeriesService} from './series.service'; import {translate} from "@jsverse/transloco"; import {UserCollection} from "../_models/collection-tag"; import {CollectionTagService} from "./collection-tag.service"; @@ -26,6 +30,11 @@ import {ReadingListService} from "./reading-list.service"; import {ChapterService} from "./chapter.service"; import {VolumeService} from "./volume.service"; import {DefaultModalOptions} from "../_models/default-modal-options"; +import {MatchSeriesModalComponent} from "../_single-module/match-series-modal/match-series-modal.component"; +import { + BulkSetReadingProfileModalComponent +} from "../cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component"; + export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -466,8 +475,18 @@ export class ActionService { }); } + async deleteMultipleVolumes(volumes: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return; + + this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => { + if (callback) { + callback(success); + } + }) + } + async deleteMultipleChapters(seriesId: number, chapterIds: Array, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters'))) return; + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: chapterIds.length}))) return; this.chapterService.deleteMultipleChapters(seriesId, chapterIds.map(c => c.id)).subscribe(() => { if (callback) { @@ -519,7 +538,7 @@ export class ActionService { addMultipleSeriesToWantToReadList(seriesIds: Array, callback?: VoidActionCallback) { this.memberService.addSeriesToWantToRead(seriesIds).subscribe(() => { - this.toastr.success('Series added to Want to Read list'); + this.toastr.success(translate('toasts.series-added-want-to-read')); if (callback) { callback(); } @@ -650,7 +669,7 @@ export class ActionService { } editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) { - const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'lg', fullscreen: 'md' }); + const readingListModalRef = this.modalService.open(EditReadingListModalComponent, DefaultModalOptions); readingListModalRef.componentInstance.readingList = readingList; readingListModalRef.closed.pipe(take(1)).subscribe((list) => { if (callback && list !== undefined) { @@ -770,6 +789,16 @@ export class ActionService { }); } + matchSeries(series: Series, callback?: BooleanActionCallback) { + const ref = this.modalService.open(MatchSeriesModalComponent, DefaultModalOptions); + ref.componentInstance.series = series; + ref.closed.subscribe(saved => { + if (callback) { + callback(saved); + } + }); + } + async deleteFilter(filterId: number, callback?: BooleanActionCallback) { if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) { if (callback) { @@ -787,4 +816,56 @@ export class ActionService { }); } + /** + * Sets the reading profile for multiple series + * @param series + * @param callback + */ + setReadingProfileForMultiple(series: Array, callback?: BooleanActionCallback) { + if (this.readingListModalRef != null) { return; } + + this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); + this.readingListModalRef.componentInstance.seriesIds = series.map(s => s.id) + this.readingListModalRef.componentInstance.title = "" + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(true); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(false); + } + }); + } + + /** + * Sets the reading profile for multiple series + * @param library + * @param callback + */ + setReadingProfileForLibrary(library: Library, callback?: BooleanActionCallback) { + if (this.readingListModalRef != null) { return; } + + this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); + this.readingListModalRef.componentInstance.libraryId = library.id; + this.readingListModalRef.componentInstance.title = "" + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(true); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(false); + } + }); + } + } diff --git a/UI/Web/src/app/_services/chapter.service.ts b/UI/Web/src/app/_services/chapter.service.ts index c722031bd..6a6f7a600 100644 --- a/UI/Web/src/app/_services/chapter.service.ts +++ b/UI/Web/src/app/_services/chapter.service.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@angular/core'; +import {Injectable} from '@angular/core'; import {environment} from "../../environments/environment"; -import { HttpClient } from "@angular/common/http"; +import {HttpClient} from "@angular/common/http"; import {Chapter} from "../_models/chapter"; import {TextResonse} from "../_types/text-response"; +import {ChapterDetailPlus} from "../_models/chapter-detail-plus"; @Injectable({ providedIn: 'root' @@ -29,4 +30,8 @@ export class ChapterService { return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse); } + chapterDetailPlus(seriesId: number, chapterId: number) { + return this.httpClient.get(this.baseUrl + `chapter/chapter-detail-plus?chapterId=${chapterId}&seriesId=${seriesId}`); + } + } diff --git a/UI/Web/src/app/_services/dashboard.service.ts b/UI/Web/src/app/_services/dashboard.service.ts index 7ece274bb..493fae370 100644 --- a/UI/Web/src/app/_services/dashboard.service.ts +++ b/UI/Web/src/app/_services/dashboard.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; +import {Injectable} from '@angular/core'; import {TextResonse} from "../_types/text-response"; -import { HttpClient } from "@angular/common/http"; +import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {DashboardStream} from "../_models/dashboard/dashboard-stream"; @@ -26,4 +26,8 @@ export class DashboardService { createDashboardStream(smartFilterId: number) { return this.httpClient.post(this.baseUrl + 'stream/add-dashboard-stream?smartFilterId=' + smartFilterId, {}); } + + deleteSmartFilterStream(streamId: number) { + return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-dashboard-stream?dashboardStreamId=' + streamId, {}); + } } diff --git a/UI/Web/src/app/_services/email.service.ts b/UI/Web/src/app/_services/email.service.ts new file mode 100644 index 000000000..5afb62ca7 --- /dev/null +++ b/UI/Web/src/app/_services/email.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {EmailHistory} from "../_models/email-history"; + +@Injectable({ + providedIn: 'root' +}) +export class EmailService { + baseUrl = environment.apiUrl; + constructor(private httpClient: HttpClient) { } + + getEmailHistory() { + return this.httpClient.get(`${this.baseUrl}email/all`); + } +} diff --git a/UI/Web/src/app/_services/filter.service.ts b/UI/Web/src/app/_services/filter.service.ts index 2c47ff95d..2b9681e90 100644 --- a/UI/Web/src/app/_services/filter.service.ts +++ b/UI/Web/src/app/_services/filter.service.ts @@ -1,8 +1,7 @@ -import { Injectable } from '@angular/core'; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {Injectable} from '@angular/core'; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {environment} from "../../environments/environment"; -import { HttpClient } from "@angular/common/http"; -import {JumpKey} from "../_models/jumpbar/jump-key"; +import {HttpClient} from "@angular/common/http"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; @Injectable({ @@ -13,7 +12,7 @@ export class FilterService { baseUrl = environment.apiUrl; constructor(private httpClient: HttpClient) { } - saveFilter(filter: SeriesFilterV2) { + saveFilter(filter: FilterV2) { return this.httpClient.post(this.baseUrl + 'filter/update', filter); } getAllFilters() { @@ -23,4 +22,7 @@ export class FilterService { return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId); } + renameSmartFilter(filter: SmartFilter) { + return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {}); + } } diff --git a/UI/Web/src/app/_services/jumpbar.service.ts b/UI/Web/src/app/_services/jumpbar.service.ts index d9919ff57..48ca08705 100644 --- a/UI/Web/src/app/_services/jumpbar.service.ts +++ b/UI/Web/src/app/_services/jumpbar.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@angular/core'; -import { JumpKey } from '../_models/jumpbar/jump-key'; +import {Injectable} from '@angular/core'; +import {JumpKey} from '../_models/jumpbar/jump-key'; const keySize = 25; // Height of the JumpBar button @@ -105,14 +105,18 @@ export class JumpbarService { getJumpKeys(data :Array, keySelector: (data: any) => string) { const keys: {[key: string]: number} = {}; data.forEach(obj => { - let ch = keySelector(obj).charAt(0).toUpperCase(); - if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { - ch = '#'; + try { + let ch = keySelector(obj).charAt(0).toUpperCase(); + if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { + ch = '#'; + } + if (!keys.hasOwnProperty(ch)) { + keys[ch] = 0; + } + keys[ch] += 1; + } catch (e) { + console.error('Failed to calculate jump key for ', obj, e); } - if (!keys.hasOwnProperty(ch)) { - keys[ch] = 0; - } - keys[ch] += 1; }); return Object.keys(keys).map(k => { k = k.toUpperCase(); diff --git a/UI/Web/src/app/_services/license.service.ts b/UI/Web/src/app/_services/license.service.ts new file mode 100644 index 000000000..a2e77f2fe --- /dev/null +++ b/UI/Web/src/app/_services/license.service.ts @@ -0,0 +1,83 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {catchError, map, ReplaySubject, tap, throwError} from "rxjs"; +import {environment} from "../../environments/environment"; +import {TextResonse} from '../_types/text-response'; +import {LicenseInfo} from "../_models/kavitaplus/license-info"; + +@Injectable({ + providedIn: 'root' +}) +export class LicenseService { + private readonly httpClient = inject(HttpClient); + + baseUrl = environment.apiUrl; + + private readonly hasValidLicenseSource = new ReplaySubject(1); + /** + * Does the user have an active license + */ + public readonly hasValidLicense$ = this.hasValidLicenseSource.asObservable(); + + + /** + * Delete the license from the server and update hasValidLicenseSource to false + */ + deleteLicense() { + return this.httpClient.delete(this.baseUrl + 'license', TextResonse).pipe( + map(res => res === "true"), + tap(_ => { + this.hasValidLicenseSource.next(false) + }), + catchError(error => { + this.hasValidLicenseSource.next(false); + return throwError(error); // Rethrow the error to propagate it further + }) + ); + } + + resetLicense(license: string, email: string) { + return this.httpClient.post(this.baseUrl + 'license/reset', {license, email}, TextResonse); + } + + /** + * Returns information about License and will internally cache if license is valid or not + */ + licenseInfo(forceCheck: boolean = false) { + return this.httpClient.get(this.baseUrl + `license/info?forceCheck=${forceCheck}`).pipe( + tap(res => { + this.hasValidLicenseSource.next(res?.isActive || false) + }), + catchError(error => { + this.hasValidLicenseSource.next(false); + return throwError(error); // Rethrow the error to propagate it further + }) + ); + } + + hasValidLicense(forceCheck: boolean = false) { + return this.httpClient.get(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse) + .pipe( + map(res => res === "true"), + tap(res => { + this.hasValidLicenseSource.next(res) + }), + catchError(error => { + this.hasValidLicenseSource.next(false); + return throwError(error); // Rethrow the error to propagate it further + }) + ); + } + + hasAnyLicense() { + return this.httpClient.get(this.baseUrl + 'license/has-license', TextResonse) + .pipe( + map(res => res === "true"), + ); + } + + updateUserLicense(license: string, email: string, discordId?: string) { + return this.httpClient.post(this.baseUrl + 'license', {license, email, discordId}, TextResonse) + .pipe(map(res => res === "true")); + } +} diff --git a/UI/Web/src/app/_services/localization.service.ts b/UI/Web/src/app/_services/localization.service.ts index 0c4e2bdbf..7519a9562 100644 --- a/UI/Web/src/app/_services/localization.service.ts +++ b/UI/Web/src/app/_services/localization.service.ts @@ -1,18 +1,36 @@ -import { Injectable } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {environment} from "../../environments/environment"; import { HttpClient } from "@angular/common/http"; -import {Language} from "../_models/metadata/language"; +import {KavitaLocale, Language} from "../_models/metadata/language"; +import {ReplaySubject, tap} from "rxjs"; +import {TranslocoService} from "@jsverse/transloco"; @Injectable({ providedIn: 'root' }) export class LocalizationService { + private readonly translocoService = inject(TranslocoService); + baseUrl = environment.apiUrl; + private readonly localeSubject = new ReplaySubject(1); + public readonly locales$ = this.localeSubject.asObservable(); + constructor(private httpClient: HttpClient) { } getLocales() { - return this.httpClient.get(this.baseUrl + 'locale'); + return this.httpClient.get(this.baseUrl + 'locale').pipe(tap(locales => { + this.localeSubject.next(locales); + })); + } + + refreshTranslations(lang: string) { + + // Clear the cached translation + localStorage.removeItem(`@@TRANSLOCO_PERSIST_TRANSLATIONS/${lang}`); + + // Reload the translation + return this.translocoService.load(lang); } } diff --git a/UI/Web/src/app/_services/manage.service.ts b/UI/Web/src/app/_services/manage.service.ts new file mode 100644 index 000000000..781830caa --- /dev/null +++ b/UI/Web/src/app/_services/manage.service.ts @@ -0,0 +1,18 @@ +import {inject, Injectable} from '@angular/core'; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {ManageMatchSeries} from "../_models/kavitaplus/manage-match-series"; +import {ManageMatchFilter} from "../_models/kavitaplus/manage-match-filter"; + +@Injectable({ + providedIn: 'root' +}) +export class ManageService { + + baseUrl = environment.apiUrl; + private readonly httpClient = inject(HttpClient); + + getAllKavitaPlusSeries(filter: ManageMatchFilter) { + return this.httpClient.post>(this.baseUrl + `manage/series-metadata`, filter); + } +} diff --git a/UI/Web/src/app/_services/member.service.ts b/UI/Web/src/app/_services/member.service.ts index b6afbd8a9..d93098995 100644 --- a/UI/Web/src/app/_services/member.service.ts +++ b/UI/Web/src/app/_services/member.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; import { Member } from '../_models/auth/member'; +import {UserTokenInfo} from "../_models/kavitaplus/user-token-info"; @Injectable({ providedIn: 'root' @@ -20,6 +21,10 @@ export class MemberService { return this.httpClient.get(this.baseUrl + 'users/names'); } + getUserTokenInfo() { + return this.httpClient.get(this.baseUrl + 'users/tokens'); + } + adminExists() { return this.httpClient.get(this.baseUrl + 'admin/exists'); } @@ -37,11 +42,11 @@ export class MemberService { } addSeriesToWantToRead(seriesIds: Array) { - return this.httpClient.post>(this.baseUrl + 'want-to-read/add-series', {seriesIds}); + return this.httpClient.post(this.baseUrl + 'want-to-read/add-series', {seriesIds}); } removeSeriesToWantToRead(seriesIds: Array) { - return this.httpClient.post>(this.baseUrl + 'want-to-read/remove-series', {seriesIds}); + return this.httpClient.post(this.baseUrl + 'want-to-read/remove-series', {seriesIds}); } getMember() { diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index ea1819bd7..f870d1449 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -1,15 +1,16 @@ -import { Injectable } from '@angular/core'; -import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; -import { BehaviorSubject, ReplaySubject } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { LibraryModifiedEvent } from '../_models/events/library-modified-event'; -import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; -import { ThemeProgressEvent } from '../_models/events/theme-progress-event'; -import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { User } from '../_models/user'; +import {Injectable} from '@angular/core'; +import {HubConnection, HubConnectionBuilder} from '@microsoft/signalr'; +import {BehaviorSubject, ReplaySubject} from 'rxjs'; +import {environment} from 'src/environments/environment'; +import {LibraryModifiedEvent} from '../_models/events/library-modified-event'; +import {NotificationProgressEvent} from '../_models/events/notification-progress-event'; +import {ThemeProgressEvent} from '../_models/events/theme-progress-event'; +import {UserUpdateEvent} from '../_models/events/user-update-event'; +import {User} from '../_models/user'; import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; +import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -109,7 +110,15 @@ export enum EVENTS { /** * A Progress event when a smart collection is synchronizing */ - SmartCollectionSync = 'SmartCollectionSync' + SmartCollectionSync = 'SmartCollectionSync', + /** + * A Person merged has been merged into another + */ + PersonMerged = 'PersonMerged', + /** + * A Rate limit error was hit when matching a series with Kavita+ + */ + ExternalMatchRateLimitError = 'ExternalMatchRateLimitError' } export interface Message { @@ -232,6 +241,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.ExternalMatchRateLimitError, resp => { + this.messagesSource.next({ + event: EVENTS.ExternalMatchRateLimitError, + payload: resp.body as ExternalMatchRateLimitErrorEvent + }); + }); + this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => { this.messagesSource.next({ event: EVENTS.NotificationProgress, @@ -336,6 +352,13 @@ export class MessageHubService { payload: resp.body }); }); + + this.hubConnection.on(EVENTS.PersonMerged, resp => { + this.messagesSource.next({ + event: EVENTS.PersonMerged, + payload: resp.body + }); + }) } stopHubConnection() { diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 314e5c37b..fe0702219 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,33 +1,54 @@ -import {HttpClient} from '@angular/common/http'; -import {Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {inject, Injectable} from '@angular/core'; import {tap} from 'rxjs/operators'; -import {of} from 'rxjs'; +import {map, of} from 'rxjs'; import {environment} from 'src/environments/environment'; import {Genre} from '../_models/metadata/genre'; import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; import {Language} from '../_models/metadata/language'; import {PublicationStatusDto} from '../_models/metadata/publication-status-dto'; -import {Person, PersonRole} from '../_models/metadata/person'; +import {allPeopleRoles, Person, PersonRole} from '../_models/metadata/person'; import {Tag} from '../_models/tag'; import {FilterComparison} from '../_models/metadata/v2/filter-comparison'; import {FilterField} from '../_models/metadata/v2/filter-field'; -import {SortField} from "../_models/metadata/series-filter"; +import {mangaFormatFilters, SortField} from "../_models/metadata/series-filter"; import {FilterCombination} from "../_models/metadata/v2/filter-combination"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {FilterStatement} from "../_models/metadata/v2/filter-statement"; import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus"; import {LibraryType} from "../_models/library/library"; import {IHasCast} from "../_models/common/i-has-cast"; import {TextResonse} from "../_types/text-response"; import {QueryContext} from "../_models/metadata/v2/query-context"; +import {AgeRatingPipe} from "../_pipes/age-rating.pipe"; +import {MangaFormatPipe} from "../_pipes/manga-format.pipe"; +import {TranslocoService} from "@jsverse/transloco"; +import {LibraryService} from './library.service'; +import {CollectionTagService} from "./collection-tag.service"; +import {PaginatedResult} from "../_models/pagination"; +import {UtilityService} from "../shared/_services/utility.service"; +import {BrowseGenre} from "../_models/metadata/browse/browse-genre"; +import {BrowseTag} from "../_models/metadata/browse/browse-tag"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonRolePipe} from "../_pipes/person-role.pipe"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' }) export class MetadataService { + private readonly translocoService = inject(TranslocoService); + private readonly libraryService = inject(LibraryService); + private readonly collectionTagService = inject(CollectionTagService); + private readonly utilityService = inject(UtilityService); + baseUrl = environment.apiUrl; private validLanguages: Array = []; + private ageRatingPipe = new AgeRatingPipe(); + private mangaFormatPipe = new MangaFormatPipe(this.translocoService); + private personRolePipe = new PersonRolePipe(); constructor(private httpClient: HttpClient) { } @@ -74,6 +95,28 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } + getGenreWithCounts(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'metadata/genres-with-counts', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + + getTagWithCounts(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'metadata/tags-with-counts', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + getAllLanguages(libraries?: Array) { let method = 'metadata/languages' if (libraries != undefined && libraries.length > 0) { @@ -110,19 +153,28 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + 'metadata/people-by-role?role=' + role); } - createDefaultFilterDto(): SeriesFilterV2 { + createDefaultFilterDto(entityType: ValidFilterEntity): FilterV2 { return { - statements: [] as FilterStatement[], + statements: [] as FilterStatement[], combination: FilterCombination.And, limitTo: 0, sortOptions: { isAscending: true, - sortField: SortField.SortName + sortField: (entityType === 'series' ? SortField.SortName : PersonSortField.Name) as TSort } }; } - createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') { + createDefaultFilterStatement(entityType: ValidFilterEntity) { + switch (entityType) { + case 'series': + return this.createFilterStatement(FilterField.SeriesName); + case 'person': + return this.createFilterStatement(PersonFilterField.Role, FilterComparison.Contains, `${PersonRole.CoverArtist},${PersonRole.Writer}`); + } + } + + createFilterStatement(field: T, comparison = FilterComparison.Equal, value = '') { return { comparison: comparison, field: field, @@ -130,7 +182,7 @@ export class MetadataService { }; } - updateFilter(arr: Array, index: number, filterStmt: FilterStatement) { + updateFilter(arr: Array>, index: number, filterStmt: FilterStatement) { arr[index].comparison = filterStmt.comparison; arr[index].field = filterStmt.field; arr[index].value = filterStmt.value ? filterStmt.value + '' : ''; @@ -140,8 +192,6 @@ export class MetadataService { switch (role) { case PersonRole.Other: break; - case PersonRole.Artist: - break; case PersonRole.CoverArtist: entity.coverArtists = persons; break; @@ -183,4 +233,85 @@ export class MetadataService { break; } } + + /** + * Used to get the underlying Options (for Metadata Filter Dropdowns) + * @param filterField + * @param entityType + */ + getOptionsForFilterField(filterField: T, entityType: ValidFilterEntity) { + + switch (entityType) { + case 'series': + return this.getSeriesOptionsForFilterField(filterField as FilterField); + case 'person': + return this.getPersonOptionsForFilterField(filterField as PersonFilterField); + } + } + + private getPersonOptionsForFilterField(field: PersonFilterField) { + switch (field) { + case PersonFilterField.Role: + return of(allPeopleRoles.map(r => {return {value: r, label: this.personRolePipe.transform(r)}})); + } + return of([]) + } + + private getSeriesOptionsForFilterField(field: FilterField) { + switch (field) { + case FilterField.PublicationStatus: + return this.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => { + return {value: pub.value, label: pub.title} + }))); + case FilterField.AgeRating: + return this.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => { + return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)} + }))); + case FilterField.Genres: + return this.getAllGenres().pipe(map(genres => genres.map(genre => { + return {value: genre.id, label: genre.title} + }))); + case FilterField.Languages: + return this.getAllLanguages().pipe(map(statuses => statuses.map(status => { + return {value: status.isoCode, label: status.title + ` (${status.isoCode})`} + }))); + case FilterField.Formats: + return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => { + return {value: status.value, label: this.mangaFormatPipe.transform(status.value)} + }))); + case FilterField.Libraries: + return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => { + return {value: lib.id, label: lib.name} + }))); + case FilterField.Tags: + return this.getAllTags().pipe(map(statuses => statuses.map(status => { + return {value: status.id, label: status.title} + }))); + case FilterField.CollectionTags: + return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => { + return {value: status.id, label: status.title} + }))); + case FilterField.Characters: return this.getPersonOptions(PersonRole.Character); + case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist); + case FilterField.CoverArtist: return this.getPersonOptions(PersonRole.CoverArtist); + case FilterField.Editor: return this.getPersonOptions(PersonRole.Editor); + case FilterField.Inker: return this.getPersonOptions(PersonRole.Inker); + case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer); + case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller); + case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher); + case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint); + case FilterField.Team: return this.getPersonOptions(PersonRole.Team); + case FilterField.Location: return this.getPersonOptions(PersonRole.Location); + case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator); + case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer); + } + + return of([]); + } + + private getPersonOptions(role: PersonRole) { + return this.getAllPeopleByRole(role).pipe(map(people => people.map(person => { + return {value: person.id, label: person.name} + }))); + } } diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 7b1e4dd8c..0aad76ef7 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,7 +1,7 @@ import {DOCUMENT} from '@angular/common'; import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core'; -import {distinctUntilChanged, filter, ReplaySubject, take} from 'rxjs'; -import { HttpClient } from "@angular/common/http"; +import {filter, ReplaySubject, take} from 'rxjs'; +import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {TextResonse} from "../_types/text-response"; @@ -9,6 +9,24 @@ import {AccountService} from "./account.service"; import {map} from "rxjs/operators"; import {NavigationEnd, Router} from "@angular/router"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; +import {WikiLink} from "../_models/wiki"; + +/** + * NavItem used to construct the dropdown or NavLinkModal on mobile + * Priority construction + * @param routerLink A link to a page on the web app, takes priority + * @param fragment Optional fragment for routerLink + * @param href A link to an external page, must set noopener noreferrer + * @param click Callback, lowest priority. Should only be used if routerLink and href or not set + */ +interface NavItem { + transLocoKey: string; + href?: string; + fragment?: string; + routerLink?: string; + click?: () => void; +} @Injectable({ providedIn: 'root' @@ -21,6 +39,33 @@ export class NavService { public localStorageSideNavKey = 'kavita--sidenav--expanded'; + public navItems: NavItem[] = [ + { + transLocoKey: 'all-filters', + routerLink: '/all-filters/', + }, + { + transLocoKey: 'browse-genres', + routerLink: '/browse/genres', + }, + { + transLocoKey: 'browse-tags', + routerLink: '/browse/tags', + }, + { + transLocoKey: 'announcements', + routerLink: '/announcements/', + }, + { + transLocoKey: 'help', + href: WikiLink.Guides, + }, + { + transLocoKey: 'logout', + click: () => this.logout(), + } + ] + private navbarVisibleSource = new ReplaySubject(1); /** * If the top Nav bar is rendered or not @@ -93,6 +138,10 @@ export class NavService { return this.httpClient.post(this.baseUrl + 'stream/bulk-sidenav-stream-visibility', {ids: streamIds, visibility: targetVisibility}); } + deleteSideNavSmartFilter(streamId: number) { + return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-side-nav-stream?sideNavStreamId=' + streamId, {}); + } + /** * Shows the top nav bar. This should be visible on all pages except the reader. */ @@ -123,6 +172,13 @@ export class NavService { }, 10); } + logout() { + this.accountService.logout(); + this.hideNavBar(); + this.hideSideNav(); + this.router.navigateByUrl('/login'); + } + /** * Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state. */ diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index 676aa6e71..fc9148135 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -1,16 +1,17 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from "@angular/common/http"; +import {Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {Person, PersonRole} from "../_models/metadata/person"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {PaginatedResult} from "../_models/pagination"; import {Series} from "../_models/series"; import {map} from "rxjs/operators"; import {UtilityService} from "../shared/_services/utility.service"; -import {BrowsePerson} from "../_models/person/browse-person"; -import {Chapter} from "../_models/chapter"; +import {BrowsePerson} from "../_models/metadata/browse/browse-person"; import {StandaloneChapter} from "../_models/standalone-chapter"; import {TextResonse} from "../_types/text-response"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' @@ -29,6 +30,10 @@ export class PersonService { return this.httpClient.get(this.baseUrl + `person?name=${name}`); } + searchPerson(name: string) { + return this.httpClient.get>(this.baseUrl + `person/search?queryString=${encodeURIComponent(name)}`); + } + getRolesForPerson(personId: number) { return this.httpClient.get>(this.baseUrl + `person/roles?personId=${personId}`); } @@ -41,18 +46,40 @@ export class PersonService { return this.httpClient.get>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`); } - getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) { + getAuthorsToBrowse(filter: FilterV2, pageNum?: number, itemsPerPage?: number) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - return this.httpClient.post>(this.baseUrl + 'person/all', {}, {observe: 'response', params}).pipe( + return this.httpClient.post>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe( map((response: any) => { return this.utilityService.createPaginatedResult(response) as PaginatedResult; }) ); } + // getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) { + // let params = new HttpParams(); + // params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + // + // return this.httpClient.post>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe( + // map((response: any) => { + // return this.utilityService.createPaginatedResult(response) as PaginatedResult; + // }) + // ); + // } + downloadCover(personId: number) { return this.httpClient.post(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); } + + isValidAlias(personId: number, alias: string) { + return this.httpClient.get(this.baseUrl + `person/valid-alias?personId=${personId}&alias=${alias}`, TextResonse).pipe( + map(valid => valid + '' === 'true') + ); + } + + mergePerson(destId: number, srcId: number) { + return this.httpClient.post(this.baseUrl + 'person/merge', {destId, srcId}); + } + } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 991a37bdb..52aef2a4a 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -1,28 +1,29 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {DestroyRef, Inject, inject, Injectable} from '@angular/core'; import {DOCUMENT, Location} from '@angular/common'; -import { Router } from '@angular/router'; -import { environment } from 'src/environments/environment'; -import { ChapterInfo } from '../manga-reader/_models/chapter-info'; -import { Chapter } from '../_models/chapter'; -import { HourEstimateRange } from '../_models/series-detail/hour-estimate-range'; -import { MangaFormat } from '../_models/manga-format'; -import { BookmarkInfo } from '../_models/manga-reader/bookmark-info'; -import { PageBookmark } from '../_models/readers/page-bookmark'; -import { ProgressBookmark } from '../_models/readers/progress-bookmark'; -import { FileDimension } from '../manga-reader/_models/file-dimension'; +import {Router} from '@angular/router'; +import {environment} from 'src/environments/environment'; +import {ChapterInfo} from '../manga-reader/_models/chapter-info'; +import {Chapter} from '../_models/chapter'; +import {HourEstimateRange} from '../_models/series-detail/hour-estimate-range'; +import {MangaFormat} from '../_models/manga-format'; +import {BookmarkInfo} from '../_models/manga-reader/bookmark-info'; +import {PageBookmark} from '../_models/readers/page-bookmark'; +import {ProgressBookmark} from '../_models/readers/progress-bookmark'; +import {FileDimension} from '../manga-reader/_models/file-dimension'; import screenfull from 'screenfull'; -import { TextResonse } from '../_types/text-response'; -import { AccountService } from './account.service'; +import {TextResonse} from '../_types/text-response'; +import {AccountService} from './account.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {PersonalToC} from "../_models/readers/personal-toc"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import NoSleep from 'nosleep.js'; import {FullProgress} from "../_models/readers/full-progress"; import {Volume} from "../_models/volume"; import {UtilityService} from "../shared/_services/utility.service"; import {translate} from "@jsverse/transloco"; import {ToastrService} from "ngx-toastr"; +import {FilterField} from "../_models/metadata/v2/filter-field"; export const CHAPTER_ID_DOESNT_EXIST = -1; @@ -46,7 +47,8 @@ export class ReaderService { // Override background color for reader and restore it onDestroy private originalBodyColor!: string; - private noSleep = new NoSleep(); + + private noSleep: NoSleep = new NoSleep(); constructor(private httpClient: HttpClient, @Inject(DOCUMENT) private document: Document) { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { @@ -56,17 +58,18 @@ export class ReaderService { }); } + enableWakeLock(element?: Element | Document) { // Enable wake lock. // (must be wrapped in a user input event handler e.g. a mouse or touch handler) if (!element) element = this.document; - const enableNoSleepHandler = () => { + const enableNoSleepHandler = async () => { element!.removeEventListener('click', enableNoSleepHandler, false); element!.removeEventListener('touchmove', enableNoSleepHandler, false); element!.removeEventListener('mousemove', enableNoSleepHandler, false); - this.noSleep!.enable(); + await this.noSleep.enable(); }; // Enable wake lock. @@ -105,7 +108,7 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page}); } - getAllBookmarks(filter: SeriesFilterV2 | undefined) { + getAllBookmarks(filter: FilterV2 | undefined) { return this.httpClient.post(this.baseUrl + 'reader/all-bookmarks', filter); } @@ -263,13 +266,13 @@ export class ReaderService { getQueryParamsObject(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) { - let params: {[key: string]: any} = {}; - if (incognitoMode) { - params['incognitoMode'] = true; - } + const params: {[key: string]: any} = {}; + params['incognitoMode'] = incognitoMode; + if (readingListMode) { params['readingListId'] = readingListId; } + return params; } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 04d97060f..088263a33 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -1,13 +1,13 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { UtilityService } from '../shared/_services/utility.service'; -import { Person } from '../_models/metadata/person'; -import { PaginatedResult } from '../_models/pagination'; -import { ReadingList, ReadingListItem } from '../_models/reading-list'; -import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary'; -import { TextResonse } from '../_types/text-response'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {Person, PersonRole} from '../_models/metadata/person'; +import {PaginatedResult} from '../_models/pagination'; +import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list'; +import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary'; +import {TextResonse} from '../_types/text-response'; import {Action, ActionItem} from './action-factory.service'; @Injectable({ @@ -20,7 +20,7 @@ export class ReadingListService { constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } getReadingList(readingListId: number) { - return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); + return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); } getReadingLists(includePromoted: boolean = true, sortByLastModified: boolean = false, pageNum?: number, itemsPerPage?: number) { @@ -114,10 +114,20 @@ export class ReadingListService { return this.httpClient.post(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); } - getCharacters(readingListId: number) { - return this.httpClient.get>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId); + getPeople(readingListId: number, role: PersonRole) { + return this.httpClient.get>(this.baseUrl + `readinglist/people?readingListId=${readingListId}&role=${role}`); } + getAllPeople(readingListId: number) { + return this.httpClient.get(this.baseUrl + `readinglist/all-people?readingListId=${readingListId}`); + } + + + getReadingListInfo(readingListId: number) { + return this.httpClient.get(this.baseUrl + `readinglist/info?readingListId=${readingListId}`); + } + + promoteMultipleReadingLists(listIds: Array, promoted: boolean) { return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse); } diff --git a/UI/Web/src/app/_services/reading-profile.service.ts b/UI/Web/src/app/_services/reading-profile.service.ts new file mode 100644 index 000000000..e8be8b6ab --- /dev/null +++ b/UI/Web/src/app/_services/reading-profile.service.ts @@ -0,0 +1,70 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {environment} from "../../environments/environment"; +import {ReadingProfile} from "../_models/preferences/reading-profiles"; + +@Injectable({ + providedIn: 'root' +}) +export class ReadingProfileService { + + private readonly httpClient = inject(HttpClient); + baseUrl = environment.apiUrl; + + getForSeries(seriesId: number, skipImplicit: boolean = false) { + return this.httpClient.get(this.baseUrl + `reading-profile/${seriesId}?skipImplicit=${skipImplicit}`); + } + + getForLibrary(libraryId: number) { + return this.httpClient.get(this.baseUrl + `reading-profile/library?libraryId=${libraryId}`); + } + + updateProfile(profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + 'reading-profile', profile); + } + + updateParentProfile(seriesId: number, profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + `reading-profile/update-parent?seriesId=${seriesId}`, profile); + } + + createProfile(profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + 'reading-profile/create', profile); + } + + promoteProfile(profileId: number) { + return this.httpClient.post(this.baseUrl + "reading-profile/promote?profileId=" + profileId, {}); + } + + updateImplicit(profile: ReadingProfile, seriesId: number) { + return this.httpClient.post(this.baseUrl + "reading-profile/series?seriesId="+seriesId, profile); + } + + getAllProfiles() { + return this.httpClient.get(this.baseUrl + 'reading-profile/all'); + } + + delete(id: number) { + return this.httpClient.delete(this.baseUrl + `reading-profile?profileId=${id}`); + } + + addToSeries(id: number, seriesId: number) { + return this.httpClient.post(this.baseUrl + `reading-profile/series/${seriesId}?profileId=${id}`, {}); + } + + clearSeriesProfiles(seriesId: number) { + return this.httpClient.delete(this.baseUrl + `reading-profile/series/${seriesId}`, {}); + } + + addToLibrary(id: number, libraryId: number) { + return this.httpClient.post(this.baseUrl + `reading-profile/library/${libraryId}?profileId=${id}`, {}); + } + + clearLibraryProfiles(libraryId: number) { + return this.httpClient.delete(this.baseUrl + `reading-profile/library/${libraryId}`, {}); + } + + bulkAddToSeries(id: number, seriesIds: number[]) { + return this.httpClient.post(this.baseUrl + `reading-profile/bulk?profileId=${id}`, seriesIds); + } + +} diff --git a/UI/Web/src/app/_services/review.service.ts b/UI/Web/src/app/_services/review.service.ts new file mode 100644 index 000000000..b8635bcf8 --- /dev/null +++ b/UI/Web/src/app/_services/review.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import {UserReview} from "../_single-module/review-card/user-review"; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {Rating} from "../_models/rating"; + +@Injectable({ + providedIn: 'root' +}) +export class ReviewService { + + private baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + deleteReview(seriesId: number, chapterId?: number) { + if (chapterId) { + return this.httpClient.delete(this.baseUrl + `review/chapter?chapterId=${chapterId}`); + } + + return this.httpClient.delete(this.baseUrl + `review/series?seriesId=${seriesId}`); + } + + updateReview(seriesId: number, body: string, chapterId?: number) { + if (chapterId) { + return this.httpClient.post(this.baseUrl + `review/chapter`, { + seriesId, chapterId, body + }); + } + + return this.httpClient.post(this.baseUrl + 'review/series', { + seriesId, body + }); + } + + updateRating(seriesId: number, userRating: number, chapterId?: number) { + if (chapterId) { + return this.httpClient.post(this.baseUrl + 'rating/chapter', { + seriesId, chapterId, userRating + }) + } + + return this.httpClient.post(this.baseUrl + 'rating/series', { + seriesId, userRating + }) + } + + overallRating(seriesId: number, chapterId?: number) { + if (chapterId) { + return this.httpClient.get(this.baseUrl + `rating/overall-chapter?chapterId=${chapterId}`); + } + + return this.httpClient.get(this.baseUrl + `rating/overall-series?seriesId=${seriesId}`); + } + +} diff --git a/UI/Web/src/app/_services/scrobbling.service.ts b/UI/Web/src/app/_services/scrobbling.service.ts index fa2942ce8..cfc7b34ac 100644 --- a/UI/Web/src/app/_services/scrobbling.service.ts +++ b/UI/Web/src/app/_services/scrobbling.service.ts @@ -1,8 +1,8 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; +import {HttpClient, HttpParams} from '@angular/common/http'; import {Injectable} from '@angular/core'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { TextResonse } from '../_types/text-response'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {TextResonse} from '../_types/text-response'; import {ScrobbleError} from "../_models/scrobbling/scrobble-error"; import {ScrobbleEvent} from "../_models/scrobbling/scrobble-event"; import {ScrobbleHold} from "../_models/scrobbling/scrobble-hold"; @@ -12,9 +12,10 @@ import {UtilityService} from "../shared/_services/utility.service"; export enum ScrobbleProvider { Kavita = 0, - AniList= 1, + AniList = 1, Mal = 2, - GoogleBooks = 3 + GoogleBooks = 3, + Cbr = 4 } @Injectable({ @@ -32,12 +33,20 @@ export class ScrobblingService { .pipe(map(r => r === "true")); } + /** + * Returns if the token was new or not + */ updateAniListToken(token: string) { - return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token}); + return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token}, TextResonse) + .pipe(map(r => r + '' === 'true')); } + /** + * Returns if the token was new or not + */ updateMalToken(username: string, accessToken: string) { - return this.httpClient.post(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken}); + return this.httpClient.post(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken}, TextResonse) + .pipe(map(r => r + '' === 'true')); } getAniListToken() { @@ -48,6 +57,11 @@ export class ScrobblingService { return this.httpClient.get<{username: string, accessToken: string}>(this.baseUrl + 'scrobbling/mal-token'); } + + hasRunScrobbleGen() { + return this.httpClient.get(this.baseUrl + 'scrobbling/has-ran-scrobble-gen ', TextResonse).pipe(map(r => r === 'true')); + } + getScrobbleErrors() { return this.httpClient.get>(this.baseUrl + 'scrobbling/scrobble-errors'); } @@ -87,4 +101,13 @@ export class ScrobblingService { removeHold(seriesId: number) { return this.httpClient.delete(this.baseUrl + 'scrobbling/remove-hold?seriesId=' + seriesId, TextResonse); } + + triggerScrobbleEventGeneration() { + return this.httpClient.post(this.baseUrl + 'scrobbling/generate-scrobble-events', TextResonse); + } + + bulkRemoveEvents(eventIds: number[]) { + return this.httpClient.post(this.baseUrl + "scrobbling/bulk-remove-events", eventIds) + } + } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index ba3fadde1..9c436e636 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -1,26 +1,26 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { UtilityService } from '../shared/_services/utility.service'; -import { Chapter } from '../_models/chapter'; -import { PaginatedResult } from '../_models/pagination'; -import { Series } from '../_models/series'; -import { RelatedSeries } from '../_models/series-detail/related-series'; -import { SeriesDetail } from '../_models/series-detail/series-detail'; -import { SeriesGroup } from '../_models/series-group'; -import { SeriesMetadata } from '../_models/metadata/series-metadata'; -import { Volume } from '../_models/volume'; -import { ImageService } from './image.service'; -import { TextResonse } from '../_types/text-response'; -import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2'; -import {UserReview} from "../_single-module/review-card/user-review"; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {Chapter} from '../_models/chapter'; +import {PaginatedResult} from '../_models/pagination'; +import {Series} from '../_models/series'; +import {RelatedSeries} from '../_models/series-detail/related-series'; +import {SeriesDetail} from '../_models/series-detail/series-detail'; +import {SeriesGroup} from '../_models/series-group'; +import {SeriesMetadata} from '../_models/metadata/series-metadata'; +import {Volume} from '../_models/volume'; +import {TextResonse} from '../_types/text-response'; +import {FilterV2} from '../_models/metadata/v2/filter-v2'; import {Rating} from "../_models/rating"; import {Recommendation} from "../_models/series-detail/recommendation"; import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail"; import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter"; import {QueryContext} from "../_models/metadata/v2/query-context"; +import {ExternalSeriesMatch} from "../_models/series-detail/external-series-match"; +import {FilterField} from "../_models/metadata/v2/filter-field"; @Injectable({ providedIn: 'root' @@ -31,10 +31,9 @@ export class SeriesService { paginatedResults: PaginatedResult = new PaginatedResult(); paginatedSeriesForTagsResults: PaginatedResult = new PaginatedResult(); - constructor(private httpClient: HttpClient, private imageService: ImageService, - private utilityService: UtilityService) { } + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } - getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2, context: QueryContext = QueryContext.None) { + getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2, context: QueryContext = QueryContext.None) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -46,7 +45,7 @@ export class SeriesService { ); } - getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -82,10 +81,6 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'series/delete-multiple', {seriesIds}, TextResonse).pipe(map(s => s === "true")); } - updateRating(seriesId: number, userRating: number) { - return this.httpClient.post(this.baseUrl + 'series/update-rating', {seriesId, userRating}); - } - updateSeries(model: any) { return this.httpClient.post(this.baseUrl + 'series/update', model); } @@ -98,7 +93,7 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'reader/mark-unread', {seriesId}); } - getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); @@ -114,7 +109,7 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'series/recently-updated-series', {}); } - getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2): Observable> { + getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: FilterV2): Observable> { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -132,7 +127,7 @@ export class SeriesService { })); } - getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -201,27 +196,9 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/series-detail?seriesId=' + seriesId); } - - - deleteReview(seriesId: number) { - return this.httpClient.delete(this.baseUrl + 'review?seriesId=' + seriesId); - } - updateReview(seriesId: number, body: string) { - return this.httpClient.post(this.baseUrl + 'review', { - seriesId, body - }); - } - - getReviews(seriesId: number) { - return this.httpClient.get>(this.baseUrl + 'review?seriesId=' + seriesId); - } - getRatings(seriesId: number) { return this.httpClient.get>(this.baseUrl + 'rating?seriesId=' + seriesId); } - getOverallRating(seriesId: number) { - return this.httpClient.get(this.baseUrl + 'rating/overall?seriesId=' + seriesId); - } removeFromOnDeck(seriesId: number) { return this.httpClient.post(this.baseUrl + 'series/remove-from-on-deck?seriesId=' + seriesId, {}); @@ -235,4 +212,15 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/next-expected?seriesId=' + seriesId); } + matchSeries(model: any) { + return this.httpClient.post>(this.baseUrl + 'series/match', model); + } + + updateMatch(seriesId: number, series: ExternalSeriesDetail) { + return this.httpClient.post(this.baseUrl + `series/update-match?seriesId=${seriesId}&aniListId=${series.aniListId || 0}&malId=${series.malId || 0}&cbrId=${series.cbrId || 0}`, {}, TextResonse); + } + + updateDontMatch(seriesId: number, dontMatch: boolean) { + return this.httpClient.post(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse); + } } diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 6e635c472..4a71e836e 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -41,10 +41,6 @@ export class ServerService { return this.http.post(this.baseUrl + 'server/backup-db', {}); } - analyzeFiles() { - return this.http.post(this.baseUrl + 'server/analyze-files', {}); - } - syncThemes() { return this.http.post(this.baseUrl + 'server/sync-themes', {}); } @@ -53,17 +49,13 @@ export class ServerService { return this.http.get(this.baseUrl + 'server/check-update'); } - checkHowOutOfDate() { - return this.http.get(this.baseUrl + 'server/checkHowOutOfDate', TextResonse) + checkHowOutOfDate(stableOnly: boolean = true) { + return this.http.get(this.baseUrl + `server/check-out-of-date?stableOnly=${stableOnly}`, TextResonse) .pipe(map(r => parseInt(r, 10))); } - checkForUpdates() { - return this.http.get(this.baseUrl + 'server/check-for-updates', {}); - } - - getChangelog() { - return this.http.get(this.baseUrl + 'server/changelog', {}); + getChangelog(count: number = 0) { + return this.http.get(this.baseUrl + 'server/changelog?count=' + count, {}); } getRecurringJobs() { diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 1a0984f2b..cf80765f2 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -1,20 +1,19 @@ -import { HttpClient } from '@angular/common/http'; +import {HttpClient, HttpParams} from '@angular/common/http'; import {Inject, inject, Injectable} from '@angular/core'; -import { environment } from 'src/environments/environment'; -import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; -import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; -import {asyncScheduler, finalize, map, tap} from 'rxjs'; -import { MangaFormatPipe } from '../_pipes/manga-format.pipe'; -import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; -import { TopUserRead } from '../statistics/_models/top-reads'; -import { ReadHistoryEvent } from '../statistics/_models/read-history-event'; -import { ServerStatistics } from '../statistics/_models/server-statistics'; -import { StatCount } from '../statistics/_models/stat-count'; -import { PublicationStatus } from '../_models/metadata/publication-status'; -import { MangaFormat } from '../_models/manga-format'; -import { TextResonse } from '../_types/text-response'; +import {environment} from 'src/environments/environment'; +import {UserReadStatistics} from '../statistics/_models/user-read-statistics'; +import {PublicationStatusPipe} from '../_pipes/publication-status.pipe'; +import {asyncScheduler, map} from 'rxjs'; +import {MangaFormatPipe} from '../_pipes/manga-format.pipe'; +import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown'; +import {TopUserRead} from '../statistics/_models/top-reads'; +import {ReadHistoryEvent} from '../statistics/_models/read-history-event'; +import {ServerStatistics} from '../statistics/_models/server-statistics'; +import {StatCount} from '../statistics/_models/stat-count'; +import {PublicationStatus} from '../_models/metadata/publication-status'; +import {MangaFormat} from '../_models/manga-format'; +import {TextResonse} from '../_types/text-response'; import {TranslocoService} from "@jsverse/transloco"; -import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown"; import {throttleTime} from "rxjs/operators"; import {DEBOUNCE_TIME} from "../shared/_services/download.service"; import {download} from "../shared/_models/download"; @@ -44,11 +43,14 @@ export class StatisticsService { constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { } getUserStatistics(userId: number, libraryIds: Array = []) { - // TODO: Convert to httpParams object - let url = 'stats/user/' + userId + '/read'; - if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(','); + const url = `${this.baseUrl}stats/user/${userId}/read`; - return this.httpClient.get(this.baseUrl + url); + let params = new HttpParams(); + if (libraryIds.length > 0) { + params = params.set('libraryIds', libraryIds.join(',')); + } + + return this.httpClient.get(url, { params }); } getServerStatistics() { @@ -59,7 +61,7 @@ export class StatisticsService { return this.httpClient.get[]>(this.baseUrl + 'stats/server/count/year').pipe( map(spreads => spreads.map(spread => { return {name: spread.value + '', value: spread.count}; - }))); + }))); } getTopYears() { @@ -134,8 +136,4 @@ export class StatisticsService { getDayBreakdown( userId = 0) { return this.httpClient.get>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId); } - - getKavitaPlusMetadataBreakdown() { - return this.httpClient.get(this.baseUrl + 'stats/kavitaplus-metadata-breakdown'); - } } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 95293baea..3e186f8ac 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -44,6 +44,9 @@ export class ThemeService { private themesSource = new ReplaySubject(1); public themes$ = this.themesSource.asObservable(); + + private darkModeSource = new ReplaySubject(1); + public isDarkMode$ = this.darkModeSource.asObservable(); /** * Maintain a cache of themes. SignalR will inform us if we need to refresh cache @@ -237,9 +240,11 @@ export class ThemeService { } this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); }); } else { this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); } } else { // Only time themes isn't already loaded is on first load diff --git a/UI/Web/src/app/_services/toggle.service.ts b/UI/Web/src/app/_services/toggle.service.ts index 8b335394a..0ad9813e3 100644 --- a/UI/Web/src/app/_services/toggle.service.ts +++ b/UI/Web/src/app/_services/toggle.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; -import { NavigationStart, Router } from '@angular/router'; -import { filter, ReplaySubject, take } from 'rxjs'; +import {Injectable} from '@angular/core'; +import {NavigationStart, Router} from '@angular/router'; +import {filter, ReplaySubject, take} from 'rxjs'; @Injectable({ providedIn: 'root' @@ -29,7 +29,7 @@ export class ToggleService { this.toggleState = !state; this.toggleStateSource.next(this.toggleState); }); - + } set(state: boolean) { diff --git a/UI/Web/src/app/_services/version.service.ts b/UI/Web/src/app/_services/version.service.ts new file mode 100644 index 000000000..ed9d8bac6 --- /dev/null +++ b/UI/Web/src/app/_services/version.service.ts @@ -0,0 +1,250 @@ +import {inject, Injectable, OnDestroy} from '@angular/core'; +import {interval, Subscription, switchMap} from 'rxjs'; +import {ServerService} from "./server.service"; +import {AccountService} from "./account.service"; +import {filter, take} from "rxjs/operators"; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component"; +import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component"; +import {Router} from "@angular/router"; + +@Injectable({ + providedIn: 'root' +}) +export class VersionService implements OnDestroy{ + + private readonly serverService = inject(ServerService); + private readonly accountService = inject(AccountService); + private readonly modalService = inject(NgbModal); + private readonly router = inject(Router); + + public static readonly SERVER_VERSION_KEY = 'kavita--version'; + public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown'; + public static readonly NEW_UPDATE_KEY = 'kavita--new-update-last-shown'; + public static readonly OUT_OF_BAND_KEY = 'kavita--out-of-band-last-shown'; + + // Notification intervals + private readonly CLIENT_REFRESH_INTERVAL = 0; // Show immediately (once) + private readonly NEW_UPDATE_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds + private readonly OUT_OF_BAND_INTERVAL = 30 * 24 * 60 * 60 * 1000; // 1 month in milliseconds + + // Check intervals + private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes + private readonly OUT_OF_DATE_CHECK_INTERVAL = 2 * 60 * 60 * 1000; // 2 hours + private readonly OUT_Of_BAND_AMOUNT = 3; // How many releases before we show "You're X releases out of date" + + // Routes where version update modals should not be shown + private readonly EXCLUDED_ROUTES = [ + '/manga/', + '/book/', + '/pdf/', + '/reader/' + ]; + + + private versionCheckSubscription?: Subscription; + private outOfDateCheckSubscription?: Subscription; + private modalOpen = false; + + constructor() { + this.startInitialVersionCheck(); + this.startVersionCheck(); + this.startOutOfDateCheck(); + } + + ngOnDestroy() { + this.versionCheckSubscription?.unsubscribe(); + this.outOfDateCheckSubscription?.unsubscribe(); + } + + /** + * Initial version check to ensure localStorage is populated on first load + */ + private startInitialVersionCheck(): void { + this.accountService.currentUser$ + .pipe( + filter(user => !!user), + take(1), + switchMap(user => this.serverService.getVersion(user!.apiKey)) + ) + .subscribe(serverVersion => { + const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); + + // Always update localStorage on first load + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); + + console.log('Initial version check - Server version:', serverVersion, 'Cached version:', cachedVersion); + }); + } + + /** + * Periodic check for server version to detect client refreshes and new updates + */ + private startVersionCheck(): void { + console.log('Starting version checker'); + this.versionCheckSubscription = interval(this.VERSION_CHECK_INTERVAL) + .pipe( + switchMap(() => this.accountService.currentUser$), + filter(user => !!user && !this.modalOpen), + switchMap(user => this.serverService.getVersion(user!.apiKey)), + filter(update => !!update), + ).subscribe(version => this.handleVersionUpdate(version)); + } + + /** + * Checks if the server is out of date compared to the latest release + */ + private startOutOfDateCheck() { + console.log('Starting out-of-date checker'); + this.outOfDateCheckSubscription = interval(this.OUT_OF_DATE_CHECK_INTERVAL) + .pipe( + switchMap(() => this.accountService.currentUser$), + filter(u => u !== undefined && this.accountService.hasAdminRole(u) && !this.modalOpen), + switchMap(_ => this.serverService.checkHowOutOfDate(true)), + filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > this.OUT_Of_BAND_AMOUNT), + ) + .subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate)); + } + + /** + * Checks if the current route is in the excluded routes list + */ + private isExcludedRoute(): boolean { + const currentUrl = this.router.url; + return this.EXCLUDED_ROUTES.some(route => currentUrl.includes(route)); + } + + /** + * Handles the version check response to determine if client refresh or new update notification is needed + */ + private handleVersionUpdate(serverVersion: string) { + if (this.modalOpen) return; + + // Validate if we are on a reader route and if so, suppress + if (this.isExcludedRoute()) { + console.log('Version update blocked due to user reading'); + return; + } + + const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); + console.log('Server version:', serverVersion, 'Cached version:', cachedVersion); + + const isNewServerVersion = cachedVersion !== null && cachedVersion !== serverVersion; + + // Case 1: Client Refresh needed (server has updated since last client load) + if (isNewServerVersion) { + this.showClientRefreshNotification(serverVersion); + } + // Case 2: Check for new updates (for server admin) + else { + this.checkForNewUpdates(); + } + + // Always update the cached version + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); + } + + /** + * Shows a notification that client refresh is needed due to server update + */ + private showClientRefreshNotification(newVersion: string): void { + this.pauseChecks(); + + // Client refresh notifications should always show (once) + this.modalOpen = true; + + this.serverService.getChangelog(1).subscribe(changelog => { + const ref = this.modalService.open(NewUpdateModalComponent, { + size: 'lg', + keyboard: false, + backdrop: 'static' // Prevent closing by clicking outside + }); + + ref.componentInstance.version = newVersion; + ref.componentInstance.update = changelog[0]; + ref.componentInstance.requiresRefresh = true; + + // Update the last shown timestamp + localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + }); + } + + /** + * Checks for new server updates and shows notification if appropriate + */ + private checkForNewUpdates(): void { + this.accountService.currentUser$ + .pipe( + take(1), + filter(user => user !== undefined && this.accountService.hasAdminRole(user)), + switchMap(_ => this.serverService.checkHowOutOfDate()), + filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0 && versionsOutOfDate <= this.OUT_Of_BAND_AMOUNT) + ) + .subscribe(versionsOutOfDate => { + const lastShown = Number(localStorage.getItem(VersionService.NEW_UPDATE_KEY) || '0'); + const currentTime = Date.now(); + + // Show notification if it hasn't been shown in the last week + if (currentTime - lastShown >= this.NEW_UPDATE_INTERVAL) { + this.pauseChecks(); + this.modalOpen = true; + + this.serverService.getChangelog(1).subscribe(changelog => { + const ref = this.modalService.open(NewUpdateModalComponent, { size: 'lg' }); + ref.componentInstance.versionsOutOfDate = versionsOutOfDate; + ref.componentInstance.update = changelog[0]; + ref.componentInstance.requiresRefresh = false; + + // Update the last shown timestamp + localStorage.setItem(VersionService.NEW_UPDATE_KEY, currentTime.toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + }); + } + }); + } + + /** + * Handles the notification for servers that are significantly out of date + */ + private handleOutOfDateNotification(versionsOutOfDate: number): void { + const lastShown = Number(localStorage.getItem(VersionService.OUT_OF_BAND_KEY) || '0'); + const currentTime = Date.now(); + + // Show notification if it hasn't been shown in the last month + if (currentTime - lastShown >= this.OUT_OF_BAND_INTERVAL) { + this.pauseChecks(); + this.modalOpen = true; + + const ref = this.modalService.open(OutOfDateModalComponent, { size: 'xl', fullscreen: 'md' }); + ref.componentInstance.versionsOutOfDate = versionsOutOfDate; + + // Update the last shown timestamp + localStorage.setItem(VersionService.OUT_OF_BAND_KEY, currentTime.toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + } + } + + /** + * Pauses all version checks while modals are open + */ + private pauseChecks(): void { + this.versionCheckSubscription?.unsubscribe(); + this.outOfDateCheckSubscription?.unsubscribe(); + } + + /** + * Resumes all checks when modals are closed + */ + private onModalClosed(): void { + this.modalOpen = false; + this.startVersionCheck(); + this.startOutOfDateCheck(); + } +} diff --git a/UI/Web/src/app/_services/volume.service.ts b/UI/Web/src/app/_services/volume.service.ts index 16857b3d2..8c9f9e17e 100644 --- a/UI/Web/src/app/_services/volume.service.ts +++ b/UI/Web/src/app/_services/volume.service.ts @@ -21,7 +21,12 @@ export class VolumeService { return this.httpClient.delete(this.baseUrl + 'volume?volumeId=' + volumeId); } + deleteMultipleVolumes(volumeIds: number[]) { + return this.httpClient.post(this.baseUrl + "volume/multiple", volumeIds) + } + updateVolume(volume: any) { return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse); } + } diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html index 067dc5fb2..7573c554a 100644 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html @@ -1,7 +1,9 @@