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/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/pr-check.yml b/.github/workflows/pr-check.yml index 7482deb0b..7243968dc 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -9,7 +9,7 @@ on: 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..38ec425fe 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 Exe diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index df946c10b..0032df72d 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 diff --git a/API/API.csproj b/API/API.csproj index 0c9aae840..2ca86b600 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -2,7 +2,7 @@ Default - net8.0 + net9.0 true Linux true @@ -12,10 +12,10 @@ latestmajor - - - - + + + + false @@ -55,8 +55,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -64,49 +64,49 @@ - - - + + + - + - - - - - - - + + + + + + + - - - + + + - - + + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - + + + - + @@ -192,6 +192,7 @@ + Always diff --git a/API/Constants/CacheProfiles.cs b/API/Constants/CacheProfiles.cs index ee2cd204e..d70452cfe 100644 --- a/API/Constants/CacheProfiles.cs +++ b/API/Constants/CacheProfiles.cs @@ -12,6 +12,10 @@ public static class EasyCacheProfiles /// public const string License = "license"; /// + /// License Information + /// + public const string LicenseInfo = "licenseInfo"; + /// /// Cache the libraries on the server /// public const string Library = "library"; diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index a05210711..0c5c8ac0c 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -15,6 +15,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; diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 3b9f8cdda..78918704d 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -1,5 +1,8 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; using API.Data.ManualMigrations; +using API.DTOs; using API.DTOs.Progress; using API.Entities; using Microsoft.AspNetCore.Authorization; @@ -13,10 +16,12 @@ namespace API.Controllers; public class AdminController : BaseApiController { private readonly UserManager _userManager; + private readonly IUnitOfWork _unitOfWork; - public AdminController(UserManager userManager) + public AdminController(UserManager userManager, IUnitOfWork unitOfWork) { _userManager = userManager; + _unitOfWork = unitOfWork; } /// 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/LicenseController.cs b/API/Controllers/LicenseController.cs index d831f10b2..0584f7319 100644 --- a/API/Controllers/LicenseController.cs +++ b/API/Controllers/LicenseController.cs @@ -2,11 +2,12 @@ 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 Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -41,7 +42,7 @@ public class LicenseController( } /// - /// Has any license + /// Has any license registered with the instance. Does not check Kavita+ API /// /// [Authorize("RequireAdminRole")] @@ -53,6 +54,19 @@ 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) + { + return Ok(await licenseService.GetLicenseInfo(forceCheck)); + } + [Authorize("RequireAdminRole")] [HttpDelete] [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] @@ -67,6 +81,7 @@ public class LicenseController( return Ok(); } + [Authorize("RequireAdminRole")] [HttpPost("reset")] public async Task ResetLicense(UpdateLicenseDto dto) 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..1e6ec0ae8 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -32,12 +32,15 @@ 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)); } @@ -189,12 +192,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 diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index e122ae9f9..6d49b4ee1 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -764,6 +764,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 +787,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 +820,7 @@ public class OpdsController : BaseApiController }); } - foreach (var readingListDto in series.ReadingLists) + foreach (var readingListDto in searchResults.ReadingLists) { feed.Entries.Add(new FeedEntry() { @@ -827,6 +834,7 @@ public class OpdsController : BaseApiController }); } + // TODO: Search should allow Chapters/Files and more return CreateXmlResult(SerializeXml(feed)); } diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index bb5ca1aea..bb35b5974 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -73,7 +73,7 @@ public class PersonController : BaseApiController /// /// /// - [Authorize("AdminRequired")] + [Authorize("RequireAdminRole")] [HttpPost("update")] public async Task> UpdatePerson(UpdatePersonDto dto) { @@ -135,7 +135,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); diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs index 685f3e2a1..19e9d36b2 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; @@ -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(); } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 2cf97d9b6..f72d36a0f 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -9,6 +9,7 @@ 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; @@ -616,4 +617,42 @@ 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) + { + return Ok(await _externalMetadataService.MatchSeries(dto)); + } + + /// + /// This will perform the fix match + /// + /// + /// + /// + [HttpPost("update-match")] + public async Task UpdateSeriesMatch(ExternalSeriesDetailDto dto, [FromQuery] int seriesId) + { + await _externalMetadataService.FixSeriesMatch(seriesId, dto); + + 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..38b72c65b 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -213,15 +213,16 @@ public class ServerController : BaseApiController /// /// 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..5bca31c95 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -389,7 +389,6 @@ public class SettingsController : BaseApiController { if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) { - //if (updateSettingsDto.TotalBackup) setting.Value = updateSettingsDto.TaskBackup; _unitOfWork.SettingsRepository.Update(setting); 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/UsersController.cs b/API/Controllers/UsersController.cs index 7639053ba..e290b3b9f 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -5,8 +5,10 @@ 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; @@ -23,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")] @@ -173,4 +177,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/DTOs/Email/EmailHistoryDto.cs b/API/DTOs/Email/EmailHistoryDto.cs new file mode 100644 index 000000000..ca3549550 --- /dev/null +++ b/API/DTOs/Email/EmailHistoryDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace API.DTOs.Email; + +public class 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/Account/AniListUpdateDto.cs b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs similarity index 63% rename from API/DTOs/Account/AniListUpdateDto.cs rename to API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs index d51a1dc0d..c6d2e07cc 100644 --- a/API/DTOs/Account/AniListUpdateDto.cs +++ b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs.Account; +namespace API.DTOs.KavitaPlus.Account; public class AniListUpdateDto { diff --git a/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs b/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs new file mode 100644 index 000000000..220bd9e7e --- /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 class 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..99d4c619d --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs @@ -0,0 +1,16 @@ +using API.DTOs.Scrobbling; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; + +/// +/// 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 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..00806aef8 --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using API.DTOs.Scrobbling; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; + +internal class MatchSeriesRequestDto +{ + public 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 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..a00896a03 --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using API.DTOs.Scrobbling; +using API.DTOs.SeriesDetail; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; + +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; } +} diff --git a/API/DTOs/License/EncryptLicenseDto.cs b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs similarity index 84% rename from API/DTOs/License/EncryptLicenseDto.cs rename to API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs index 97015c470..140c41e4c 100644 --- a/API/DTOs/License/EncryptLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; public class EncryptLicenseDto { diff --git a/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs new file mode 100644 index 000000000..398556aac --- /dev/null +++ b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs @@ -0,0 +1,35 @@ +using System; + +namespace API.DTOs.KavitaPlus.License; + +public class 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 76% rename from API/DTOs/Account/LicenseValidDto.cs rename to API/DTOs/KavitaPlus/License/LicenseValidDto.cs index f49420779..56ee6cf73 100644 --- a/API/DTOs/Account/LicenseValidDto.cs +++ b/API/DTOs/KavitaPlus/License/LicenseValidDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs.Account; +namespace API.DTOs.KavitaPlus.License; public class LicenseValidDto { diff --git a/API/DTOs/License/ResetLicenseDto.cs b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs similarity index 81% rename from API/DTOs/License/ResetLicenseDto.cs rename to API/DTOs/KavitaPlus/License/ResetLicenseDto.cs index f62d78870..60496ee0e 100644 --- a/API/DTOs/License/ResetLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; public class ResetLicenseDto { diff --git a/API/DTOs/License/UpdateLicenseDto.cs b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs similarity index 90% rename from API/DTOs/License/UpdateLicenseDto.cs rename to API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs index b2803952c..d5d6847ba 100644 --- a/API/DTOs/License/UpdateLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; public class UpdateLicenseDto { diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs new file mode 100644 index 000000000..60bed32b0 --- /dev/null +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs @@ -0,0 +1,19 @@ +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 class ManageMatchFilterDto +{ + public MatchStateOption MatchStateOption { get; set; } = MatchStateOption.All; + 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..14617e7f0 --- /dev/null +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace API.DTOs.KavitaPlus.Manage; + +public class ManageMatchSeriesDto +{ + public SeriesDto Series { get; set; } + public bool IsMatched { get; set; } + public DateTime ValidUntilUtc { get; set; } +} diff --git a/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs new file mode 100644 index 000000000..aefd697ba --- /dev/null +++ b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs @@ -0,0 +1,9 @@ +using API.DTOs.Recommendation; + +namespace API.DTOs.Metadata.Matching; + +public class 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..1f401e787 --- /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 class 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/Recommendation/ExternalSeriesDetailDto.cs b/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs index 9aa852fd7..efed70ba3 100644 --- a/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs +++ b/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs @@ -11,7 +11,7 @@ public class ExternalSeriesDetailDto public int? AniListId { get; set; } public long? MALId { get; set; } public IList Synonyms { get; set; } - public MediaFormat PlusMediaFormat { get; set; } + public PlusMediaFormat PlusMediaFormat { get; set; } public string? SiteUrl { get; set; } public string? CoverUrl { get; set; } public IList Genres { get; set; } diff --git a/API/DTOs/Recommendation/ExternalSeriesDto.cs b/API/DTOs/Recommendation/ExternalSeriesDto.cs index 55d2d320c..d393443af 100644 --- a/API/DTOs/Recommendation/ExternalSeriesDto.cs +++ b/API/DTOs/Recommendation/ExternalSeriesDto.cs @@ -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/Scrobbling/PlusSeriesDto.cs b/API/DTOs/Scrobbling/PlusSeriesDto.cs index 552a86575..587a21e2c 100644 --- a/API/DTOs/Scrobbling/PlusSeriesDto.cs +++ b/API/DTOs/Scrobbling/PlusSeriesDto.cs @@ -8,7 +8,7 @@ public record PlusSeriesDto public string? MangaDexId { 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..e8420e785 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, @@ -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/SeriesDto.cs b/API/DTOs/SeriesDto.cs index 445044eef..214a357b4 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -67,6 +67,16 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage /// The last time the folder for this series was scanned /// public DateTime LastFolderScanned { 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 string? CoverImage { get; set; } public string PrimaryColor { get; set; } diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index a83aa072b..8fb5146d5 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -1,4 +1,7 @@ -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 @@ -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,8 @@ 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 +54,16 @@ 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; } + /// + /// The part above the changelog part + /// + public string BlogPart { get; set; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 21b7c26c8..58f74bee5 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -5,6 +5,7 @@ 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.Scrobble; @@ -68,6 +69,7 @@ public sealed class DataContext : IdentityDbContext AppUserCollection { get; set; } = null!; public DbSet ChapterPeople { get; set; } = null!; public DbSet SeriesMetadataPeople { get; set; } = null!; + public DbSet EmailHistory { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/ManualMigrations/ManualMigrateBlacklistTableToSeries.cs b/API/Data/ManualMigrations/ManualMigrateBlacklistTableToSeries.cs new file mode 100644 index 000000000..8fd7ec7fd --- /dev/null +++ b/API/Data/ManualMigrations/ManualMigrateBlacklistTableToSeries.cs @@ -0,0 +1,56 @@ +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 }; + 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/ManualMigrateEncodeSettings.cs b/API/Data/ManualMigrations/ManualMigrateEncodeSettings.cs index e71f583ba..fc8b2e586 100644 --- a/API/Data/ManualMigrations/ManualMigrateEncodeSettings.cs +++ b/API/Data/ManualMigrations/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/ManualMigrateLooseLeafChapters.cs b/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs index 93fc569e8..fac184dc9 100644 --- a/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs +++ b/API/Data/ManualMigrations/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/ManualMigrateMixedSpecials.cs index 4e22abfb8..cda83f05b 100644 --- a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs +++ b/API/Data/ManualMigrations/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/ManualMigrateRemovePeople.cs b/API/Data/ManualMigrations/ManualMigrateRemovePeople.cs index 6966c0264..01d9ad45d 100644 --- a/API/Data/ManualMigrations/ManualMigrateRemovePeople.cs +++ b/API/Data/ManualMigrations/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/ManualMigrateSwitchToWal.cs b/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs index 8e648b025..21abfdf10 100644 --- a/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs +++ b/API/Data/ManualMigrations/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/ManualMigrateThemeDescription.cs index 8ac000f0d..e137afe7b 100644 --- a/API/Data/ManualMigrations/ManualMigrateThemeDescription.cs +++ b/API/Data/ManualMigrations/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/ManualMigrateUnscrobbleBookLibraries.cs b/API/Data/ManualMigrations/ManualMigrateUnscrobbleBookLibraries.cs index 02c4886cb..4f0ed3f96 100644 --- a/API/Data/ManualMigrations/ManualMigrateUnscrobbleBookLibraries.cs +++ b/API/Data/ManualMigrations/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/MigrateChapterFields.cs b/API/Data/ManualMigrations/MigrateChapterFields.cs index f157850fa..7d1f2dd12 100644 --- a/API/Data/ManualMigrations/MigrateChapterFields.cs +++ b/API/Data/ManualMigrations/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/MigrateChapterNumber.cs index 23f256874..e31fa4b92 100644 --- a/API/Data/ManualMigrations/MigrateChapterNumber.cs +++ b/API/Data/ManualMigrations/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/MigrateChapterRange.cs index f50cd2e2e..70a4b30f6 100644 --- a/API/Data/ManualMigrations/MigrateChapterRange.cs +++ b/API/Data/ManualMigrations/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/MigrateClearNightlyExternalSeriesRecords.cs b/API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs index 9eff55bc1..89485fd71 100644 --- a/API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs +++ b/API/Data/ManualMigrations/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/MigrateCollectionTagToUserCollections.cs b/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs index 038809aab..e29e706d0 100644 --- a/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs +++ b/API/Data/ManualMigrations/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/MigrateDuplicateDarkTheme.cs index 2e31c3392..e414cd8cc 100644 --- a/API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs +++ b/API/Data/ManualMigrations/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/MigrateInitialInstallData.cs b/API/Data/ManualMigrations/MigrateInitialInstallData.cs index f572034d1..199258240 100644 --- a/API/Data/ManualMigrations/MigrateInitialInstallData.cs +++ b/API/Data/ManualMigrations/MigrateInitialInstallData.cs @@ -4,6 +4,7 @@ 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; diff --git a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs b/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs index 48978d630..2a68ca3d6 100644 --- a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs +++ b/API/Data/ManualMigrations/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/MigrateLowestSeriesFolderPath2.cs b/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs index bb79c3359..00233852a 100644 --- a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs +++ b/API/Data/ManualMigrations/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/MigrateMangaFilePath.cs b/API/Data/ManualMigrations/MigrateMangaFilePath.cs index ccf9aa773..1dbc7f325 100644 --- a/API/Data/ManualMigrations/MigrateMangaFilePath.cs +++ b/API/Data/ManualMigrations/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/MigrateManualHistory.cs b/API/Data/ManualMigrations/MigrateManualHistory.cs index b9ba1263c..eaf63c41c 100644 --- a/API/Data/ManualMigrations/MigrateManualHistory.cs +++ b/API/Data/ManualMigrations/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/MigrateProgressExport.cs b/API/Data/ManualMigrations/MigrateProgressExport.cs index 2482939c0..631daeea8 100644 --- a/API/Data/ManualMigrations/MigrateProgressExport.cs +++ b/API/Data/ManualMigrations/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/MigrateSeriesLowestFolderPath.cs b/API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs index ca68392cd..8e0db3c10 100644 --- a/API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs +++ b/API/Data/ManualMigrations/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/MigrateSmartFilterEncoding.cs b/API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs index e684ef6a0..d36859e69 100644 --- a/API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs +++ b/API/Data/ManualMigrations/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/MigrateVolumeLookupName.cs b/API/Data/ManualMigrations/MigrateVolumeLookupName.cs index 9a2a4dbeb..38b7cfbba 100644 --- a/API/Data/ManualMigrations/MigrateVolumeLookupName.cs +++ b/API/Data/ManualMigrations/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/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/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index ddcfeb10e..cf8fafc2d 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -1000,6 +1000,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") @@ -1866,12 +1917,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"); @@ -2660,6 +2717,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") 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..57c2ed3ac 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> GetAllSeries(ManageMatchFilterDto filter); } public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepository @@ -107,7 +106,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .FirstOrDefaultAsync(); } - public async Task ExternalSeriesMetadataNeedsRefresh(int seriesId) + public async Task NeedsDataRefresh(int seriesId) { var row = await _context.ExternalSeriesMetadata .Where(s => s.SeriesId == seriesId) @@ -115,7 +114,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor return row == null || row.ValidUntilUtc <= DateTime.UtcNow; } - public async Task GetSeriesDetailPlusDto(int seriesId) + public async Task GetSeriesDetailPlusDto(int seriesId) { var seriesDetailDto = await _context.ExternalSeriesMetadata .Where(m => m.SeriesId == seriesId) @@ -180,13 +179,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,45 +202,12 @@ 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) { @@ -261,4 +220,14 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .Take(limit) .ToListAsync(); } + + public async Task> GetAllSeries(ManageMatchFilterDto filter) + { + return await _context.Series + .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) + .FilterMatchState(filter.MatchStateOption) + .OrderBy(s => s.NormalizedName) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/ScrobbleEventRepository.cs b/API/Data/Repositories/ScrobbleEventRepository.cs index 7d3567831..848e0ca38 100644 --- a/API/Data/Repositories/ScrobbleEventRepository.cs +++ b/API/Data/Repositories/ScrobbleEventRepository.cs @@ -29,6 +29,7 @@ public interface IScrobbleRepository Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType); Task> GetUserEventsForSeries(int userId, int seriesId); Task> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination); + Task> GetAllEventsForSeries(int seriesId); } /// @@ -153,4 +154,10 @@ public class ScrobbleRepository : IScrobbleRepository 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(); + } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 91a186746..19d2a1337 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -15,6 +15,7 @@ using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.Metadata; using API.DTOs.ReadingLists; +using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.Search; using API.DTOs.SeriesDetail; @@ -165,6 +166,7 @@ public interface ISeriesRepository Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None); Task GetPlusSeriesDto(int seriesId); Task GetCountAsync(); + Task MatchSeries(ExternalSeriesDetailDto externalSeries); } public class SeriesRepository : ISeriesRepository @@ -709,7 +711,7 @@ public class SeriesRepository : ISeriesRepository .Where(s => s.Id == seriesId) .Select(series => new PlusSeriesDto() { - 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, @@ -2037,9 +2039,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 +2072,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/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 40e614e59..8fe413e99 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,6 +16,7 @@ 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; @@ -96,6 +98,8 @@ public interface IUserRepository Task> GetSideNavStreamsByLibraryId(int libraryId); Task> GetSideNavStreamWithExternalSource(int externalSourceId); Task> GetDashboardStreamsByIds(IList streamIds); + Task> GetUserTokenInfo(); + Task GetUserByDeviceEmail(string deviceEmail); } public class UserRepository : IUserRepository @@ -490,6 +494,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() { diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index e3c1ffcb1..c4a07dee7 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -32,6 +32,7 @@ public interface IUnitOfWork IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } + IEmailHistoryRepository EmailHistoryRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -72,6 +73,7 @@ public class UnitOfWork : IUnitOfWork AppUserSmartFilterRepository = new AppUserSmartFilterRepository(_context, _mapper); AppUserExternalSourceRepository = new AppUserExternalSourceRepository(_context, _mapper); ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper); + EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper); } /// @@ -100,6 +102,7 @@ public class UnitOfWork : IUnitOfWork public IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } public IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } + public IEmailHistoryRepository EmailHistoryRepository { get; } /// /// Commits changes to the DB. Completes the open transaction. 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..a62e5fab9 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -76,6 +76,8 @@ public class AppUser : IdentityUser, IHasConcurrencyToken ///
public string? MalAccessToken { get; set; } + + /// /// A list of Series the user doesn't want scrobbling for /// 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/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/Metadata/ExternalSeriesMetadata.cs b/API/Entities/Metadata/ExternalSeriesMetadata.cs index 598d02184..ccbe53c10 100644 --- a/API/Entities/Metadata/ExternalSeriesMetadata.cs +++ b/API/Entities/Metadata/ExternalSeriesMetadata.cs @@ -23,7 +23,7 @@ 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 long MalId { 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/Scrobble/ScrobbleEvent.cs b/API/Entities/Scrobble/ScrobbleEvent.cs index a02363992..b8708c115 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 /// 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/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index b5c76b443..e1155a3e7 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -24,8 +24,6 @@ public static class ApplicationServiceExtensions { services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly); - //services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -52,7 +50,6 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -77,6 +74,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSqLite(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); @@ -84,12 +82,13 @@ public static class ApplicationServiceExtensions services.AddEasyCaching(options => { options.UseInMemory(EasyCacheProfiles.Favicon); - options.UseInMemory(EasyCacheProfiles.License); options.UseInMemory(EasyCacheProfiles.Library); options.UseInMemory(EasyCacheProfiles.RevokedJwt); // KavitaPlus stuff options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries); + options.UseInMemory(EasyCacheProfiles.License); + options.UseInMemory(EasyCacheProfiles.LicenseInfo); }); services.AddMemoryCache(options => diff --git a/API/Extensions/FlurlExtensions.cs b/API/Extensions/FlurlExtensions.cs new file mode 100644 index 000000000..38e75fbc8 --- /dev/null +++ b/API/Extensions/FlurlExtensions.cs @@ -0,0 +1,21 @@ +using System; +using Flurl.Http; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; + +namespace API.Extensions; + +public static class FlurlExtensions +{ + public static IFlurlRequest WithKavitaPlusHeaders(this string request, string license) + { + return request + .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)); + } +} diff --git a/API/Extensions/PlusMediaFormatExtensions.cs b/API/Extensions/PlusMediaFormatExtensions.cs new file mode 100644 index 000000000..74d3fe531 --- /dev/null +++ b/API/Extensions/PlusMediaFormatExtensions.cs @@ -0,0 +1,50 @@ +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 => new[] { LibraryType.Manga, LibraryType.Image }, + PlusMediaFormat.Comic => new[] { LibraryType.Comic, LibraryType.ComicVine }, + PlusMediaFormat.LightNovel => new[] { LibraryType.LightNovel, LibraryType.Book, LibraryType.Manga }, + _ => throw new ArgumentOutOfRangeException(nameof(plusMediaFormat), plusMediaFormat, null) + }; + } + + + public static IList GetMangaFormats(this PlusMediaFormat? mediaFormat) + { + if (mediaFormat == null) return [MangaFormat.Archive]; + 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], + PlusMediaFormat.Unknown => [MangaFormat.Archive], + _ => [MangaFormat.Archive] + }; + } +} diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index 571e9430c..3b3a28241 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data.Misc; using API.Data.Repositories; using API.DTOs.Filtering; +using API.DTOs.KavitaPlus.Manage; using API.Entities; using API.Entities.Enums; using API.Entities.Scrobble; @@ -281,4 +282,17 @@ 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.Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted), + MatchStateOption.NotMatched => query.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted), + MatchStateOption.Error => query.Where(s => s.IsBlacklisted), + MatchStateOption.DontMatch => query.Where(s => s.DontMatch), + _ => throw new ArgumentOutOfRangeException(nameof(stateOption), stateOption, null) + }; + } } diff --git a/API/Extensions/VersionExtensions.cs b/API/Extensions/VersionExtensions.cs new file mode 100644 index 000000000..4198c2e42 --- /dev/null +++ b/API/Extensions/VersionExtensions.cs @@ -0,0 +1,31 @@ +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; + } + + + /// + /// v0.8.2.3 is within v0.8.2 (v1). Essentially checks if this is a Nightly of a stable release + /// + /// + /// + /// + public static bool IsWithinStableRelease(this Version v1, Version v2) + { + return v1.Major == v2.Major && v1.Minor != v2.Minor && v1.Build != v2.Build; + } + + +} diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 6c8ad418f..fc2b9b059 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -8,8 +8,10 @@ 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.MediaErrors; using API.DTOs.Metadata; using API.DTOs.Progress; @@ -32,6 +34,8 @@ 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; @@ -334,9 +338,21 @@ public class AutoMapperProfiles : Profile opt.MapFrom(src => ReviewService.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.ValidUntilUtc)); 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)) diff --git a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs index 6a8e70bde..9ef9ad115 100644 --- a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs +++ b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs @@ -2,6 +2,7 @@ using API.DTOs; using API.DTOs.Scrobbling; using API.Entities; +using API.Extensions; using API.Services.Plus; namespace API.Helpers.Builders; @@ -19,7 +20,7 @@ public class PlusSeriesDtoBuilder : IEntityBuilder { _seriesDto = new PlusSeriesDto() { - 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/DayOfWeekHelper.cs b/API/Helpers/DayOfWeekHelper.cs new file mode 100644 index 000000000..4d523a8f9 --- /dev/null +++ b/API/Helpers/DayOfWeekHelper.cs @@ -0,0 +1,18 @@ +using System; + +namespace API.Extensions; + +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..db56f73fd 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -12,8 +12,10 @@ 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 diff --git a/API/Helpers/JwtHelper.cs b/API/Helpers/JwtHelper.cs new file mode 100644 index 000000000..60bdee0af --- /dev/null +++ b/API/Helpers/JwtHelper.cs @@ -0,0 +1,40 @@ +using System; +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); + var exp = token.Claims.FirstOrDefault(c => c.Type == "exp")?.Value; + + if (long.TryParse(exp, 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/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/I18N/en.json b/API/I18N/en.json index 418427111..bf2a79766 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -207,6 +207,7 @@ "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", "volume-num": "Volume {0}", "book-num": "Book {0}", diff --git a/API/Program.cs b/API/Program.cs index 544d568d1..196f09045 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -38,9 +38,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() diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 338ea0537..ed9e3431b 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -31,6 +31,10 @@ 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; } @@ -89,6 +93,7 @@ public class DirectoryService : IDirectoryService 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; @@ -126,6 +131,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); } /// diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 0a8ba6404..91f81813e 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -8,7 +8,10 @@ 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.Extensions; using MailKit.Security; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; @@ -29,6 +32,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 +48,9 @@ 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); } public class EmailService : IEmailService @@ -56,6 +64,14 @@ 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 EmailService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, IHostEnvironment environment, ILocalizationService localizationService) { @@ -104,12 +120,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 +156,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 +173,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 +198,66 @@ 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 an invite email to a user to setup their account /// @@ -195,7 +273,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,8 +300,9 @@ 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", + Template = EmailPasswordResetTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailPasswordResetTemplate), placeholders), + Preheader = "Email confirmation is required for continued access. Click the button to confirm your email.", ToEmails = new List() { dto.EmailAddress @@ -242,11 +322,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() }; @@ -302,21 +380,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/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 4bc55eb88..798caef9b 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; 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.Metadata.Matching; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -15,44 +16,24 @@ using API.Entities.Enums; using API.Entities.Metadata; using API.Extensions; using API.Helpers; +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 ForceKavitaPlusRefresh(int seriesId); Task FetchExternalDataTask(); /// /// This is an entry point and provides a level of protection against calling upstream API. Will only allow 100 new @@ -64,6 +45,9 @@ public interface IExternalMetadataService Task GetNewSeriesData(int seriesId, LibraryType libraryType); Task> GetStacksForUser(int userId); + Task> MatchSeries(MatchSeriesDto dto); + Task FixSeriesMatch(int seriesId, ExternalSeriesDetailDto dto); + Task UpdateSeriesDontMatch(int seriesId, bool dontMatch); } public class ExternalMetadataService : IExternalMetadataService @@ -72,9 +56,11 @@ 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 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, LibraryType.ComicVine]; private readonly SeriesDetailPlusDto _defaultReturn = new() { Recommendations = null, @@ -84,16 +70,17 @@ public class ExternalMetadataService : IExternalMetadataService // Allow 50 requests per 24 hours private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); - public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, ILicenseService licenseService) + public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, + ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub) { _unitOfWork = unitOfWork; _logger = logger; _mapper = mapper; _licenseService = licenseService; + _scrobblingService = scrobblingService; + _eventHub = eventHub; - - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } /// @@ -110,7 +97,7 @@ 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)] @@ -138,21 +125,24 @@ public class ExternalMetadataService : IExternalMetadataService /// /// /// - 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(); - } + // public async Task ForceKavitaPlusRefresh(int seriesId) + // { + // // TODO: I think we can remove this now + // if (!await _licenseService.HasActiveLicense()) return; + // var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId); + // if (!IsPlusEligible(libraryType)) return; + // + // // Remove from Blacklist if applicable + // var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + // series!.IsBlacklisted = false; + // _unitOfWork.SeriesRepository.Update(series); + // + // var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId); + // if (metadata == null) return; + // + // metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache); + // await _unitOfWork.CommitAsync(); + // } /// /// Fetches data from Kavita+ @@ -198,13 +188,7 @@ public class ExternalMetadataService : IExternalMetadataService 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)) + .WithKavitaPlusHeaders(license) .GetJsonAsync>(); if (result == null) @@ -221,6 +205,72 @@ public class ExternalMetadataService : IExternalMetadataService } } + /// + /// Returns the match results for a Series from UI Flow + /// + /// + /// + public async Task> MatchSeries(MatchSeriesDto dto) + { + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, + SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata); + + var potentialAnilistId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.AniListWeblinkWebsite); + var potentialMalId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.MalWeblinkWebsite); + + List altNames = [series.LocalizedName, series.OriginalName]; + if (potentialAnilistId == null && potentialMalId == null && !string.IsNullOrEmpty(dto.Query)) + { + altNames.Add(dto.Query); + } + + var matchRequest = new MatchSeriesRequestDto() + { + Format = series.Format == MangaFormat.Epub ? PlusMediaFormat.LightNovel : PlusMediaFormat.Manga, + Query = dto.Query, + SeriesName = series.Name, + AlternativeNames = altNames, + Year = series.Metadata.ReleaseYear, + AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), + MalId = potentialMalId ?? ScrobblingService.GetMalId(series), + }; + + try + { + var results = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series") + .WithKavitaPlusHeaders(license) + .PostJsonAsync(matchRequest) + .ReceiveJson>(); + + // Some summaries can contain multiple
s, we need to ensure it's only 1 + foreach (var result in results) + { + result.Series.Summary = CleanSummary(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 string CleanSummary(string? summary) + { + if (string.IsNullOrWhiteSpace(summary)) + { + return string.Empty; // Return as is if null, empty, or whitespace. + } + + return summary.Replace("
", string.Empty); + } + + + /// /// Retrieves Metadata about a Recommended External Series /// @@ -249,16 +299,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 +318,105 @@ 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 + return await FetchExternalMetadataForSeries(seriesId, libraryType, data); + } + + /// + /// 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, ExternalSeriesDetailDto dto) + { + 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 + await FetchExternalMetadataForSeries(seriesId, series.Library.Type, new PlusSeriesDto() + { + SeriesName = dto.Name, + AniListId = dto.AniListId, + MalId = dto.MALId, + MediaFormat = dto.PlusMediaFormat, + }); + + // Find all scrobble events and rewrite them to be the correct + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + _unitOfWork.ScrobbleRepository.Remove(events); + await _unitOfWork.CommitAsync(); + + // Regenerate all events for the series for all users + BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistoryForSeries(seriesId)); + await _eventHub.SendMessageAsync(MessageFactory.Info, + MessageFactory.InfoEvent($"Fix Match: {series.Name}", "Scrobble Events are regenerating with the new match")); + + + _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, dto.Name); + } + + /// + /// Sets a series to Dont 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, PlusSeriesDto data) + { + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null) return _defaultReturn; + try { - var data = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(seriesId); - if (data == null) return _defaultReturn; _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", data.SeriesName); - 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)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(data) .ReceiveJson(); // 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); @@ -339,17 +468,24 @@ public class ExternalMetadataService : IExternalMetadataService } catch (Exception ex) { - _logger.LogError(ex, "An error happened during the request to Kavita+ API"); + _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; } - private async Task GetExternalSeriesMetadataForSeries(int seriesId, Series series) + /// + /// 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; @@ -454,20 +590,14 @@ 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)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(payload) .ReceiveJson(); @@ -479,16 +609,4 @@ public class ExternalMetadataService : IExternalMetadataService return null; } - - private static MediaFormat ConvertToMediaFormat(LibraryType libraryType, MangaFormat seriesFormat) - { - 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 - }; - } } diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index 5a12f7c0b..fb513e248 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,20 @@ 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 * * *"; + public const string Cron = "0 */9 * * *"; private const string CacheKey = "license"; + private const string LicenseInfoCacheKey = "license-info"; /// @@ -53,13 +59,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 +87,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 +112,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 @@ -192,13 +156,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 +188,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 +211,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 +237,67 @@ 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; + } + + + 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 || BuildInfo.Version.IsWithinStableRelease(new Version(r.UpdateVersion))) // 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..87d3c89ad 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -10,6 +10,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; @@ -20,6 +21,7 @@ using Hangfire; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Plus; @@ -54,6 +56,7 @@ public interface IScrobblingService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ProcessUpdatesSinceLastSync(); Task CreateEventsFromExistingHistory(int userId = 0); + Task CreateEventsFromExistingHistoryForSeries(int seriesId = 0); Task ClearEventsForSeries(int userId, int seriesId); } @@ -64,6 +67,7 @@ public class ScrobblingService : IScrobblingService private readonly ILogger _logger; private readonly ILicenseService _licenseService; private readonly ILocalizationService _localizationService; + private readonly IEmailService _emailService; public const string AniListWeblinkWebsite = "https://anilist.co/manga/"; public const string MalWeblinkWebsite = "https://myanimelist.net/manga/"; @@ -99,16 +103,16 @@ public class ScrobblingService : IScrobblingService public ScrobblingService(IUnitOfWork unitOfWork, IEventHub eventHub, ILogger logger, - ILicenseService licenseService, ILocalizationService localizationService) + ILicenseService licenseService, ILocalizationService localizationService, IEmailService emailService) { _unitOfWork = unitOfWork; _eventHub = eventHub; _logger = logger; _licenseService = licenseService; _localizationService = localizationService; + _emailService = emailService; - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } @@ -123,13 +127,76 @@ 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) + { + var hasAlreadySentReminder = await _unitOfWork.DataContext.EmailHistory + .AnyAsync(h => h.AppUserId == userId && h.Sent && + h.EmailTemplate == EmailService.TokenExpiringSoonTemplate && + h.SendDate >= earlyReminderDate); + + return !hasAlreadySentReminder; + } + + return false; + } + + /// + /// Checks if an expiration notification email should be sent. + /// + private async Task ShouldSendExpirationReminder(int userId, DateTime tokenExpiry) + { + if (tokenExpiry <= DateTime.UtcNow) + { + var hasAlreadySentExpirationEmail = await _unitOfWork.DataContext.EmailHistory + .AnyAsync(h => h.AppUserId == userId && h.Sent && + h.EmailTemplate == EmailService.TokenExpirationTemplate && + h.SendDate >= tokenExpiry); + + return !hasAlreadySentExpirationEmail; + } + + return false; + } + + public async Task HasTokenExpired(int userId, ScrobbleProvider provider) { var token = await GetTokenForProvider(userId, provider); @@ -156,13 +223,7 @@ public class ScrobblingService : IScrobblingService 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)) + .WithKavitaPlusHeaders(license.Value) .GetStringAsync(); return bool.Parse(response); @@ -230,7 +291,7 @@ public class ScrobblingService : IScrobblingService AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), MalId = GetMalId(series), AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), ReviewBody = reviewBody, ReviewTitle = reviewTitle }; @@ -277,7 +338,7 @@ 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); @@ -285,16 +346,16 @@ public class ScrobblingService : IScrobblingService _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId}", series.Name, userId); } - private static long? GetMalId(Series series) + public static long? GetMalId(Series series) { var malId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite); - return malId ?? series.ExternalSeriesMetadata.MalId; + return malId ?? series.ExternalSeriesMetadata?.MalId; } - private static int? GetAniListId(Series series) + public static int? GetAniListId(Series seriesWithExternalMetadata) { - var aniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite); - return aniListId ?? series.ExternalSeriesMetadata.AniListId; + var aniListId = ExtractId(seriesWithExternalMetadata.Metadata.WebLinks, AniListWeblinkWebsite); + return aniListId ?? seriesWithExternalMetadata.ExternalSeriesMetadata?.AniListId; } public async Task ScrobbleReadingUpdate(int userId, int seriesId) @@ -340,7 +401,7 @@ public class ScrobblingService : IScrobblingService (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId), ChapterNumber = await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId), - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), }; _unitOfWork.ScrobbleRepository.Attach(evt); @@ -360,8 +421,8 @@ 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 (await CheckIfCannotScrobble(userId, seriesId, series)) return; + _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id, onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead); @@ -375,7 +436,7 @@ 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(); @@ -384,6 +445,7 @@ public class ScrobblingService : IScrobblingService 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 UserId {UserId}'s hold list. Not scrobbling", series.Name, @@ -403,13 +465,7 @@ public class ScrobblingService : IScrobblingService 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)) + .WithKavitaPlusHeaders(license) .GetStringAsync(); return int.Parse(response); @@ -427,13 +483,7 @@ public class ScrobblingService : IScrobblingService 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)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(data) .ReceiveJson(); @@ -463,9 +513,18 @@ public class ScrobblingService : IScrobblingService 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"); + _logger.LogInformation("Kavita+ was unable to match the series: {SeriesName}", evt.Series.Name); if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) { + // Create a new ExternalMetadata entry to indicate that this is not matchable + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(evt.SeriesId, SeriesIncludes.ExternalMetadata); + if (series.ExternalSeriesMetadata == null) + { + series.ExternalSeriesMetadata = new ExternalSeriesMetadata() {SeriesId = evt.SeriesId}; + } + series!.IsBlacklisted = true; + _unitOfWork.SeriesRepository.Update(series); + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() { Comment = UnknownSeriesErrorMessage, @@ -473,7 +532,7 @@ public class ScrobblingService : IScrobblingService LibraryId = evt.LibraryId, SeriesId = evt.SeriesId }); - await _unitOfWork.ExternalSeriesMetadataRepository.CreateBlacklistedSeries(evt.SeriesId, false); + } evt.IsErrored = true; @@ -501,7 +560,7 @@ public class ScrobblingService : IScrobblingService } catch (FlurlHttpException ex) { - _logger.LogError("Scrobbling to Kavita+ API failed due to error: {ErrorMessage}", ex.Message); + _logger.LogError(ex, "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)) @@ -523,11 +582,19 @@ public class ScrobblingService : IScrobblingService } /// - /// This will back fill events from existing progress history, ratings, and want to read for users that have a valid license + /// 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; + } + var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) .ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling); @@ -535,8 +602,6 @@ public class ScrobblingService : IScrobblingService .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); @@ -553,13 +618,6 @@ public class ScrobblingService : IScrobblingService 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() { @@ -578,7 +636,59 @@ public class ScrobblingService : IScrobblingService if (series.PagesRead <= 0) continue; // Since we only scrobble when things are higher, we can await ScrobbleReadingUpdate(uId, series.Id); } + } + } + public async Task CreateEventsFromExistingHistoryForSeries(int seriesId = 0) + { + if (!await _licenseService.HasActiveLicense()) return; + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null) return; + + _logger.LogInformation("Creating Scrobbling events for Series {SeriesName}", series.Name); + + var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) + .ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling); + + var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()) + .Select(u => u.Id); + + 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 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(), + SeriesNameQuery = series.Name + }); + + foreach (var seriesProgress in seriesWithProgress) + { + if (!libAllowsScrobbling[seriesProgress.LibraryId]) continue; + if (seriesProgress.PagesRead <= 0) continue; // Since we only scrobble when things are higher, we can + await ScrobbleReadingUpdate(uId, seriesProgress.Id); + } } } @@ -856,7 +966,7 @@ public class ScrobblingService : IScrobblingService { 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); + _logger.LogCritical("Access Token for UserId: {UserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id); evt.IsErrored = true; evt.ErrorDetails = AccessTokenErrorMessage; _unitOfWork.ScrobbleRepository.Update(evt); @@ -956,6 +1066,41 @@ public class ScrobblingService : IScrobblingService return default(T?); } + /// + /// 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 (id == null) + { + 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) + { + if (id is null or 0) return string.Empty; + return $"{url}{id}/"; + } + + private async Task SetAndCheckRateLimit(IDictionary userRateLimits, AppUser user, string license) { if (string.IsNullOrEmpty(user.AniListAccessToken)) return 0; @@ -982,9 +1127,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..0ef84ec19 --- /dev/null +++ b/API/Services/Plus/WantToReadSyncService.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +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); + foreach (var user in users) + { + 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/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 2dbd4ed34..2344659ec 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.Entities.Enums; +using API.Extensions; using API.Helpers.Converters; using API.Services.Plus; using API.Services.Tasks; @@ -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,12 +209,15 @@ 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) RecurringJob.AddOrUpdate(ProcessScrobblingEventsId, () => _scrobblingService.ProcessUpdatesSinceLastSync(), - "0 */4 * * *", RecurringJobOptions); + "0 */1 * * *", RecurringJobOptions); RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(), Cron.Daily, RecurringJobOptions); @@ -218,8 +226,13 @@ public class TaskScheduler : ITaskScheduler () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 4)), 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); } diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index da83eebf6..8b1db435a 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.Logging; using NetVips; namespace API.Services.Tasks.Metadata; +#nullable enable public interface ICoverDbService { @@ -50,6 +51,10 @@ 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) @@ -229,7 +234,7 @@ public class CoverDbService : ICoverDbService _logger.LogTrace("Fetching publisher image from {Url}", personImageLink.Sanitize()); - // Download the publisher file using Flurl + // Download the file using Flurl var personStream = await personImageLink .AllowHttpStatus("2xx,304") .GetStreamAsync(); @@ -263,7 +268,7 @@ public class CoverDbService : ICoverDbService private async Task GetCoverPersonImagePath(Person person) { - var tempFile = Path.Join(_directoryService.TempDirectory, "people.yml"); + 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 +291,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,12 +312,16 @@ 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(); + + // Cache immediately + await CacheDataAsync(urlsFileName, allOverrides); + if (!string.IsNullOrEmpty(allOverrides)) { @@ -335,11 +344,16 @@ public class CoverDbService : ICoverDbService return correctSizeLink; } - private static async Task FallbackToKavitaReaderPublisher(string publisherName) + private async Task FallbackToKavitaReaderPublisher(string publisherName) { + const string publisherFileName = "publishers.txt"; 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(); + var allOverrides = await GetCachedData(publisherFileName) ?? + await $"{NewHost}publishers/{publisherFileName}".GetStringAsync(); + + // Cache immediately + await CacheDataAsync(publisherFileName, allOverrides); + if (!string.IsNullOrEmpty(allOverrides)) { @@ -369,4 +383,35 @@ public class CoverDbService : ICoverDbService return externalLink; } + + 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; + } } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index a36c8268a..bcf11b9bd 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -325,7 +325,7 @@ public class ProcessSeries : IProcessSeries 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); + _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) @@ -457,7 +457,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); } @@ -918,7 +918,7 @@ public class ProcessSeries : IProcessSeries 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) @@ -987,7 +987,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..c65219897 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -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--; } @@ -644,7 +644,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/StatsService.cs b/API/Services/Tasks/StatsService.cs index 4e6fcfb60..4faf55436 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -60,8 +60,7 @@ public class StatsService : IStatsService _emailService = emailService; _cacheService = cacheService; - FlurlHttp.ConfigureClient(ApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(ApiUrl); } /// diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index f1a6eb383..fb869a9ac 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +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 +33,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 +48,12 @@ public interface IVersionUpdaterService { Task CheckForUpdate(); Task PushUpdate(UpdateNotificationDto update); - Task> GetAllReleases(); + Task> GetAllReleases(int count = 0); Task GetNumberOfReleasesBehind(); } -public class VersionUpdaterService : IVersionUpdaterService + +public partial class VersionUpdaterService : IVersionUpdaterService { private readonly ILogger _logger; private readonly IEventHub _eventHub; @@ -57,37 +61,220 @@ 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 static string _cacheFilePath; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + + public VersionUpdaterService(ILogger logger, IEventHub eventHub, IDirectoryService directoryService) { _logger = logger; _eventHub = eventHub; + _cacheFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_releases_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() { var update = await GetGithubRelease(); - return CreateDto(update); + var dto = CreateDto(update); + + return dto; } - public async Task> GetAllReleases() + 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 + { + 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 : [], + Api = sections.TryGetValue("Api", out var api) ? api : [], + 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) + }); + } + } + } + } + + 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 (cachedReleases != null) + { + 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(); + + // 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,26 +290,56 @@ 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; } - private static bool IsVersionEqualToBuildVersion(Version updateVersion) + private static async Task?> TryGetCachedReleases() { - return updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 && - CompareWithoutRevision(BuildInfo.Version, updateVersion); + if (!File.Exists(_cacheFilePath)) return null; + + var fileInfo = new FileInfo(_cacheFilePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + var cachedData = await File.ReadAllTextAsync(_cacheFilePath); + return System.Text.Json.JsonSerializer.Deserialize>(cachedData); + } + + return null; } - private static bool CompareWithoutRevision(Version v1, Version v2) + private async Task CacheReleasesAsync(IList updates) { - 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; + try + { + var json = System.Text.Json.JsonSerializer.Serialize(updates, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(_cacheFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache releases"); + } } + + + private static bool IsVersionEqualToBuildVersion(Version updateVersion) + { + return updateVersion == BuildInfo.Version || (updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 && + BuildInfo.Version.CompareWithoutRevision(updateVersion)); + } + + public async Task GetNumberOfReleasesBehind() { var updates = await GetAllReleases(); @@ -135,18 +352,30 @@ 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, + + 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 : [], + Api = parsedSections.TryGetValue("Api", out var api) ? api : [], + BlogPart = blogPart }; } @@ -165,6 +394,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 +425,103 @@ 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); + + // 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; + } + + 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; } + } + + sealed class CommitInfo + { + public required string Sha { get; init; } + public required CommitDetail Commit { get; init; } + public required string Html_Url { get; init; } + } + + sealed class CommitDetail + { + public required string Message { get; init; } + public required CommitAuthor Author { get; init; } + } + + sealed class CommitAuthor + { + public required string Date { get; init; } + } + + 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..7e3c3c0dc 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -8,6 +8,7 @@ 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; @@ -143,12 +144,6 @@ public class TokenService : ITokenService public static bool HasTokenExpired(string? token) { - if (string.IsNullOrEmpty(token)) return true; - - var tokenHandler = new JwtSecurityTokenHandler(); - var tokenContent = tokenHandler.ReadJwtToken(token); - var validToUtc = tokenContent.ValidTo.ToUniversalTime(); - - return validToUtc < DateTime.UtcNow; + return !JwtHelper.IsTokenValid(token); } } diff --git a/API/Startup.cs b/API/Startup.cs index cf6c7ccfd..efddf2fae 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -277,6 +277,9 @@ public class Startup await MigrateDuplicateDarkTheme.Migrate(dataContext, logger); await ManualMigrateUnscrobbleBookLibraries.Migrate(dataContext, logger); + // v0.8.5 + await ManualMigrateBlacklistTableToSeries.Migrate(dataContext, logger); + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); installVersion.Value = BuildInfo.Version.ToString(); diff --git a/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/Dockerfile b/Dockerfile index 6d52acaba..98ba311ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,11 @@ WORKDIR /kavita HEALTHCHECK --interval=30s --timeout=15s --start-period=30s --retries=3 CMD curl -fsS http://localhost:5000/api/health || exit 1 -ENV DOTNET_RUNNING_IN_CONTAINER=true +ENV \ + # Enable detection of running in a container + DOTNET_RUNNING_IN_CONTAINER=true + # Set the invariant mode since ICU package isn't included (see https://github.com/dotnet/announcements/issues/20) + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true ENTRYPOINT [ "/bin/bash" ] CMD ["/entrypoint.sh"] diff --git a/Kavita.Common/Helpers/FlurlConfiguration.cs b/Kavita.Common/Helpers/FlurlConfiguration.cs new file mode 100644 index 000000000..0003546d4 --- /dev/null +++ b/Kavita.Common/Helpers/FlurlConfiguration.cs @@ -0,0 +1,36 @@ +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 => + cli.ServerCertificateCustomValidationCallback = (_, _, _, _) => true); + + 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 66bacfa45..a1f9184ef 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -1,23 +1,24 @@ - net8.0 + net9.0 kavitareader.com Kavita 0.8.4.7 en + true true - + - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - \ No newline at end of file + diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 28237dbc0..de717a238 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -30,6 +30,7 @@ "@microsoft/signalr": "^8.0.7", "@ng-bootstrap/ng-bootstrap": "^17.0.1", "@popperjs/core": "^2.11.7", + "@siemens/ngx-datatable": "^22.4.1", "@swimlane/ngx-charts": "^20.5.0", "@tweenjs/tween.js": "^23.1.3", "bootstrap": "^5.3.2", @@ -464,7 +465,6 @@ "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==", - "dev": true, "dependencies": { "@babel/core": "7.25.2", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -492,7 +492,6 @@ "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" }, @@ -507,7 +506,6 @@ "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" }, @@ -2700,6 +2698,20 @@ "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", @@ -4010,8 +4022,7 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -4518,7 +4529,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -4528,7 +4538,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7470,8 +7479,7 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -7742,7 +7750,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "node_modules/sass": { "version": "1.77.6", @@ -7776,7 +7784,6 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -8331,7 +8338,6 @@ "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" diff --git a/UI/Web/package.json b/UI/Web/package.json index 07261704c..60512a1e5 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -38,6 +38,7 @@ "@microsoft/signalr": "^8.0.7", "@ng-bootstrap/ng-bootstrap": "^17.0.1", "@popperjs/core": "^2.11.7", + "@siemens/ngx-datatable": "^22.4.1", "@swimlane/ngx-charts": "^20.5.0", "@tweenjs/tween.js": "^23.1.3", "bootstrap": "^5.3.2", 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/update-version-event.ts b/UI/Web/src/app/_models/events/update-version-event.ts index c74e49af6..a25f528f0 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,24 @@ 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; + /** + * 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..a8dc1ce06 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts @@ -0,0 +1,6 @@ +import {MatchStateOption} from "./match-state-option"; + +export interface ManageMatchFilter { + matchStateOption: MatchStateOption; + 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/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.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/wiki.ts b/UI/Web/src/app/_models/wiki.ts index 381a38638..511f9f58c 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -8,7 +8,7 @@ export enum WikiLink { 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', 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+/features/', 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', 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/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/plus-media-format.pipe.ts b/UI/Web/src/app/_pipes/plus-media-format.pipe.ts new file mode 100644 index 000000000..e76488b3b --- /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.comic'); + 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/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 623002ada..5da1cce6a 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -16,6 +16,8 @@ import { TextResonse } from '../_types/text-response'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {Action} from "./action-factory.service"; import {CoverImageSize} from "../admin/_models/cover-image-size"; +import {LicenseInfo} from "../_models/kavitaplus/license-info"; +import {LicenseService} from "./license.service"; export enum Role { Admin = 'Admin', @@ -45,6 +47,7 @@ export const allRoles = [ export class AccountService { private readonly destroyRef = inject(DestroyRef); + private readonly licenseService = inject(LicenseService); baseUrl = environment.apiUrl; userKey = 'kavita-user'; @@ -54,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 @@ -154,40 +153,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( @@ -231,7 +197,7 @@ export class AccountService { // But that really messes everything up this.messageHub.stopHubConnection(); this.messageHub.createHubConnection(this.currentUser); - this.hasValidLicense().subscribe(); + this.licenseService.hasValidLicense().subscribe(); this.startRefreshTokenTimer(); } } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 24ca2a76a..447095a82 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -112,7 +112,11 @@ export enum Action { /** * Copy settings from one entity to another */ - CopySettings = 27 + CopySettings = 27, + /** + * Match an entity with an upstream system + */ + Match = 28 } /** @@ -463,6 +467,7 @@ export class ActionFactoryService { requiresAdmin: false, children: [], }, + // { // action: Action.AddToScrobbleHold, // title: 'add-to-scrobble-hold', @@ -543,6 +548,14 @@ export class ActionFactoryService { }, ], }, + { + action: Action.Match, + title: 'match', + description: 'match-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, { action: Action.Download, title: 'download', diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 27e38f82f..d520a4a7b 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -26,6 +26,8 @@ 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"; + export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -770,6 +772,16 @@ export class ActionService { }); } + matchSeries(series: Series, callback?: BooleanActionCallback) { + const ref = this.modalService.open(MatchSeriesModalComponent, {size: 'lg'}); + 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) { 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/license.service.ts b/UI/Web/src/app/_services/license.service.ts new file mode 100644 index 000000000..f71b54f52 --- /dev/null +++ b/UI/Web/src/app/_services/license.service.ts @@ -0,0 +1,86 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {catchError, map, of, ReplaySubject, tap, throwError} from "rxjs"; +import {environment} from "../../environments/environment"; +import { TextResonse } from '../_types/text-response'; +import {LicenseInfo} from "../_models/kavitaplus/license-info"; +import {translate} from "@jsverse/transloco"; +import {ConfirmService} from "../shared/confirm.service"; + +@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) { + 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")); + } +} 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/scrobbling.service.ts b/UI/Web/src/app/_services/scrobbling.service.ts index fa2942ce8..a7670629b 100644 --- a/UI/Web/src/app/_services/scrobbling.service.ts +++ b/UI/Web/src/app/_services/scrobbling.service.ts @@ -32,12 +32,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() { @@ -87,4 +95,9 @@ 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); + + } } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index ba3fadde1..0e6729be3 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -21,6 +21,8 @@ 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 {ExternalSeries} from "../_models/series-detail/external-series"; +import {ExternalSeriesMatch} from "../_models/series-detail/external-series-match"; @Injectable({ providedIn: 'root' @@ -235,4 +237,16 @@ 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, series, 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..9b3d9b1e4 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -62,8 +62,8 @@ export class ServerService { 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..f13b29c87 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -134,8 +134,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/version.service.ts b/UI/Web/src/app/_services/version.service.ts new file mode 100644 index 000000000..45331fad2 --- /dev/null +++ b/UI/Web/src/app/_services/version.service.ts @@ -0,0 +1,97 @@ +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, tap} 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"; + +@Injectable({ + providedIn: 'root' +}) +export class VersionService implements OnDestroy{ + + private readonly serverService = inject(ServerService); + private readonly accountService = inject(AccountService); + private readonly modalService = inject(NgbModal); + + public static readonly versionKey = 'kavita--version'; + private readonly checkInterval = 600000; // 10 minutes (600000) + private periodicCheckSubscription?: Subscription; + private outOfDateCheckSubscription?: Subscription; + private modalOpen = false; + + constructor() { + this.startPeriodicUpdateCheck(); + this.startOutOfDateCheck(); + } + + ngOnDestroy() { + this.periodicCheckSubscription?.unsubscribe(); + this.outOfDateCheckSubscription?.unsubscribe(); + } + + private startOutOfDateCheck() { + // Every hour, have the UI check for an update. People seriously stay out of date + this.outOfDateCheckSubscription = interval(2* 60 * 60 * 1000) // 2 hours in milliseconds + .pipe( + switchMap(() => this.accountService.currentUser$), + filter(u => u !== undefined && this.accountService.hasAdminRole(u)), + switchMap(_ => this.serverService.checkHowOutOfDate()), + filter(versionOutOfDate => { + return !isNaN(versionOutOfDate) && versionOutOfDate > 2; + }), + tap(versionOutOfDate => { + if (!this.modalService.hasOpenModals()) { + const ref = this.modalService.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'}); + ref.componentInstance.versionsOutOfDate = versionOutOfDate; + } + }) + ) + .subscribe(); + } + + private startPeriodicUpdateCheck(): void { + console.log('Starting periodic version update checker'); + this.periodicCheckSubscription = interval(this.checkInterval) + .pipe( + switchMap(_ => this.accountService.currentUser$), + filter(user => user !== undefined && !this.modalOpen), + switchMap(user => this.serverService.getVersion(user!.apiKey)), + ).subscribe(version => this.handleVersionUpdate(version)); + } + + private handleVersionUpdate(version: string) { + if (this.modalOpen) return; + + // Pause periodic checks while the modal is open + this.periodicCheckSubscription?.unsubscribe(); + + const cachedVersion = localStorage.getItem(VersionService.versionKey); + console.log('Kavita version: ', version, ' Running version: ', cachedVersion); + + const hasChanged = cachedVersion == null || cachedVersion != version; + if (hasChanged) { + this.modalOpen = true; + + this.serverService.getChangelog(1).subscribe(changelog => { + const ref = this.modalService.open(NewUpdateModalComponent, {size: 'lg'}); + ref.componentInstance.version = version; + ref.componentInstance.update = changelog[0]; + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + + }); + + } + + localStorage.setItem(VersionService.versionKey, version); + } + + private onModalClosed() { + this.modalOpen = false; + this.startPeriodicUpdateCheck(); + } +} diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html new file mode 100644 index 000000000..6e36c6e88 --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html @@ -0,0 +1,69 @@ + +
+ + + +
+
+ diff --git a/UI/Web/src/app/admin/manage-kavitaplus/manage-kavitaplus.component.scss b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss similarity index 100% rename from UI/Web/src/app/admin/manage-kavitaplus/manage-kavitaplus.component.scss rename to UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts new file mode 100644 index 000000000..6c1d70478 --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts @@ -0,0 +1,94 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; +import {Series} from "../../_models/series"; +import {SeriesService} from "../../_services/series.service"; +import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {MatchSeriesResultItemComponent} from "../match-series-result-item/match-series-result-item.component"; +import {LoadingComponent} from "../../shared/loading/loading.component"; +import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match"; +import {ToastrService} from "ngx-toastr"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; + +@Component({ + selector: 'app-match-series-modal', + standalone: true, + imports: [ + TranslocoDirective, + MatchSeriesResultItemComponent, + LoadingComponent, + ReactiveFormsModule, + SettingItemComponent, + SettingSwitchComponent + ], + templateUrl: './match-series-modal.component.html', + styleUrl: './match-series-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MatchSeriesModalComponent implements OnInit { + private readonly cdRef = inject(ChangeDetectorRef); + private readonly seriesService = inject(SeriesService); + private readonly modalService = inject(NgbActiveModal); + private readonly toastr = inject(ToastrService); + + @Input({required: true}) series!: Series; + + formGroup = new FormGroup({}); + matches: Array = []; + isLoading = true; + + ngOnInit() { + this.formGroup.addControl('query', new FormControl('', [])); + this.formGroup.addControl('dontMatch', new FormControl(this.series?.dontMatch || false, [])); + + this.search(); + } + + search() { + this.isLoading = true; + this.cdRef.markForCheck(); + + const model: any = this.formGroup.value; + model.seriesId = this.series.id; + + if (model.dontMatch) return; + + this.seriesService.matchSeries(model).subscribe(results => { + this.isLoading = false; + this.matches = results; + this.cdRef.markForCheck(); + }); + } + + close() { + this.modalService.close(false); + } + + save() { + + const model: any = this.formGroup.value; + model.seriesId = this.series.id; + + // We need to update the dontMatch status + if (model.dontMatch) { + this.seriesService.updateDontMatch(this.series.id, model.dontMatch).subscribe(_ => { + this.modalService.close(true); + }); + } else { + this.toastr.success(translate('toasts.match-success')); + this.modalService.close(true); + } + } + + selectMatch(item: ExternalSeriesMatch) { + const data = item.series; + data.tags = data.tags || []; + data.genres = data.genres || []; + + this.seriesService.updateMatch(this.series.id, data).subscribe(_ => { + this.save(); + }); + } + +} diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html new file mode 100644 index 000000000..6d0e47e9c --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html @@ -0,0 +1,38 @@ + +
+
+ @if (item.series.coverUrl) { + + } +
+
+
{{item.series.name}}
+
+ @for(synm of item.series.synonyms; track synm; let last = $last) { + {{synm}} + @if (!last) { + , + } + } +
+ @if (item.series.summary) { +
+ +
+ } +
+
+ +
+ {{t('details')}} + @if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) { + {{t('volume-count', {num: item.series.volumeCount})}} + {{t('chapter-count', {num: item.series.chapterCount})}} + } @else { + {{t('releasing')}} + } + + {{item.series.plusMediaFormat | plusMediaFormat}} + ({{item.matchRating | translocoPercent}}) +
+
diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts new file mode 100644 index 000000000..fe482824b --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts @@ -0,0 +1,44 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + Input, + Output +} from '@angular/core'; +import {ImageComponent} from "../../shared/image/image.component"; +import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; +import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match"; +import {PercentPipe} from "@angular/common"; +import {TranslocoPercentPipe} from "@jsverse/transloco-locale"; +import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe"; + +@Component({ + selector: 'app-match-series-result-item', + standalone: true, + imports: [ + ImageComponent, + TranslocoPercentPipe, + ReadMoreComponent, + TranslocoDirective, + PlusMediaFormatPipe + ], + templateUrl: './match-series-result-item.component.html', + styleUrl: './match-series-result-item.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MatchSeriesResultItemComponent { + + private readonly cdRef = inject(ChangeDetectorRef); + + @Input({required: true}) item!: ExternalSeriesMatch; + @Output() selected: EventEmitter = new EventEmitter(); + + selectItem() { + this.selected.emit(this.item); + } + +} diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index b575a7e74..4eb69ee73 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -1,103 +1,118 @@ -
{{t('title')}}
+ +
+ +
+ + @if (tokenExpired) { +

{{t('token-expired')}}

+ } +

{{t('description')}}

{{t('not-read-warning')}}

-
-
-
- - -
-
-
-
- @if(pagination) { - - } -
+
+
+ + +
+
- - - - - - - - - - - - @for(item of events; track item; let idx = $index) { - - - - - - - - } @empty { - - } - -
- {{t('last-modified-header')}} - - {{t('type-header')}} - - {{t('series-header')}} - - {{t('data-header')}} - - {{t('is-processed-header')}} -
- {{item.lastModifiedUtc | utcToLocalTime | defaultValue }} - - {{item.scrobbleEventType | scrobbleEventType}} - - {{item.seriesName}} - - @switch (item.scrobbleEventType) { - @case (ScrobbleEventType.ChapterRead) { - @if(item.volumeNumber === LooseLeafOrDefaultNumber) { - @if (item.chapterNumber === LooseLeafOrDefaultNumber) { - {{t('special')}} - } @else { - {{t('chapter-num', {num: item.chapterNumber})}} - } - } - @else if (item.chapterNumber === LooseLeafOrDefaultNumber) { - {{t('volume-num', {num: item.volumeNumber})}} - } - @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) { - Special - } - @else { - {{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}} - } - } - @case (ScrobbleEventType.ScoreUpdated) { - {{t('rating', {r: item.rating})}} - } - @default { - {{t('not-applicable')}} - } - } - - @if(item.isProcessed) { - - } @else if (item.isErrored) { - - } @else { - - } - - {{item.isProcessed ? t('processed') : t('not-processed')}} - -
{{t('no-data')}}
+ + + + + {{t('last-modified-header')}} + + + {{value | utcToLocalTime | defaultValue }} + + + + + + {{t('type-header')}} + + + {{value | scrobbleEventType}} + + + + + + {{t('series-header')}} + + + {{item.seriesName}} + + + + + + {{t('data-header')}} + + + @switch (item.scrobbleEventType) { + @case (ScrobbleEventType.ChapterRead) { + @if(item.volumeNumber === LooseLeafOrDefaultNumber) { + @if (item.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('special')}} + } @else { + {{t('chapter-num', {num: item.chapterNumber})}} + } + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('volume-num', {num: item.volumeNumber})}} + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) { + Special + } + @else { + {{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}} + } + } + @case (ScrobbleEventType.ScoreUpdated) { + {{t('rating', {r: item.rating})}} + } + @default { + {{t('not-applicable')}} + } + } + + + + + + {{t('is-processed-header')}} + + + @if(item.isProcessed) { + + } @else if (item.isErrored) { + + } @else { + + } + + {{item.isProcessed ? t('processed') : t('not-processed')}} + + + + +
diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.scss b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.scss index 7c4315507..bf691441b 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.scss +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.scss @@ -5,3 +5,8 @@ .error { color: var(--error-color); } + +.custom-position { + right: 15px; + top: -42px; +} diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index 1b30bb168..f5f8bad6b 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -4,11 +4,11 @@ import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.se import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event"; import {ScrobbleEventTypePipe} from "../../_pipes/scrobble-event-type.pipe"; -import {NgbPagination, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter"; import {debounceTime, take} from "rxjs/operators"; import {PaginatedResult, Pagination} from "../../_models/pagination"; -import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive"; +import {SortEvent} from "../table/_directives/sortable-header.directive"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; import {translate, TranslocoModule} from "@jsverse/transloco"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; @@ -16,37 +16,57 @@ import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {ToastrService} from "ngx-toastr"; import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {CardActionablesComponent} from "../card-actionables/card-actionables.component"; + +export interface DataTablePage { + pageNumber: number, + size: number, + totalElements: number, + totalPages: number +} @Component({ selector: 'app-user-scrobble-history', standalone: true, - imports: [ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, - DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip], + imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule, + DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent], templateUrl: './user-scrobble-history.component.html', styleUrls: ['./user-scrobble-history.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class UserScrobbleHistoryComponent implements OnInit { + protected readonly SpecialVolumeNumber = SpecialVolumeNumber; + protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber; + protected readonly ColumnMode = ColumnMode; + private readonly scrobblingService = inject(ScrobblingService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); private readonly toastr = inject(ToastrService); protected readonly ScrobbleEventType = ScrobbleEventType; - pagination: Pagination | undefined; - events: Array = []; + + tokenExpired = false; formGroup: FormGroup = new FormGroup({ 'filter': new FormControl('', []) }); + events: Array = []; + isLoading: boolean = true; + pageInfo: DataTablePage = { + pageNumber: 0, + size: 10, + totalElements: 0, + totalPages: 0 + } ngOnInit() { - this.loadPage({column: 'createdUtc', direction: 'desc'}); + + this.onPageChange({offset: 0}); this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => { - if (hasExpired) { - this.toastr.error(translate('toasts.anilist-token-expired')); - } + this.tokenExpired = hasExpired; this.cdRef.markForCheck(); }); @@ -55,38 +75,41 @@ export class UserScrobbleHistoryComponent implements OnInit { }) } - onPageChange(pageNum: number) { - let prevPage = 0; - if (this.pagination) { - prevPage = this.pagination.currentPage; - this.pagination.currentPage = pageNum; - } - if (prevPage !== pageNum) { - this.loadPage(); - } + onPageChange(pageInfo: any) { + this.pageInfo.pageNumber = pageInfo.offset; + this.cdRef.markForCheck(); + this.loadPage(); } - updateSort(sortEvent: SortEvent) { - this.loadPage(sortEvent); + updateSort(data: any) { + this.loadPage({column: data.column.prop, direction: data.newValue}); } loadPage(sortEvent?: SortEvent) { - if (sortEvent && this.pagination) { - this.pagination.currentPage = 1; + if (sortEvent && this.pageInfo) { + this.pageInfo.pageNumber = 1; this.cdRef.markForCheck(); } - const page = this.pagination?.currentPage || 0; - const pageSize = this.pagination?.itemsPerPage || 0; + + const page = (this.pageInfo?.pageNumber || 0) + 1; + const pageSize = this.pageInfo?.size || 0; const isDescending = sortEvent?.direction === 'desc'; const field = this.mapSortColumnField(sortEvent?.column); const query = this.formGroup.get('filter')?.value; + this.isLoading = true; + this.cdRef.markForCheck(); + this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize) .pipe(take(1)) .subscribe((result: PaginatedResult) => { this.events = result.result; - this.pagination = result.pagination; + + this.pageInfo.totalPages = result.pagination.totalPages - 1; // ngx-datatable is 0 based, Kavita is 1 based + this.pageInfo.size = result.pagination.itemsPerPage; + this.pageInfo.totalElements = result.pagination.totalItems; + this.isLoading = false; this.cdRef.markForCheck(); }); } @@ -101,7 +124,9 @@ export class UserScrobbleHistoryComponent implements OnInit { return ScrobbleEventSortField.None; } + generateScrobbleEvents() { + this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => { - protected readonly SpecialVolumeNumber = SpecialVolumeNumber; - protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber; + }); + } } diff --git a/UI/Web/src/app/admin/email-history/email-history.component.html b/UI/Web/src/app/admin/email-history/email-history.component.html new file mode 100644 index 000000000..9ab6645ae --- /dev/null +++ b/UI/Web/src/app/admin/email-history/email-history.component.html @@ -0,0 +1,57 @@ + +

{{t('description')}}

+ + + + + + {{t('template-header')}} + + + {{item.emailTemplate}} + + + + + + + {{t('date-header')}} + + + {{item.sendDate | utcToLocalTime}} + + + + + + {{t('user-header')}} + + + {{item.toUserName}} + + + + + + {{t('sent-header')}} + + + @if (item.sent) { + + {{t('sent-tooltip')}} + + } @else { + + {{t('not-sent-tooltip')}} + + } + + + +
diff --git a/UI/Web/src/app/admin/email-history/email-history.component.scss b/UI/Web/src/app/admin/email-history/email-history.component.scss new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/UI/Web/src/app/admin/email-history/email-history.component.scss @@ -0,0 +1 @@ + diff --git a/UI/Web/src/app/admin/email-history/email-history.component.ts b/UI/Web/src/app/admin/email-history/email-history.component.ts new file mode 100644 index 000000000..1bb39f67a --- /dev/null +++ b/UI/Web/src/app/admin/email-history/email-history.component.ts @@ -0,0 +1,42 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; +import {TranslocoDirective} from "@jsverse/transloco"; +import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {EmailHistory} from "../../_models/email-history"; +import {EmailService} from "../../_services/email.service"; +import {LoadingComponent} from "../../shared/loading/loading.component"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; + +@Component({ + selector: 'app-email-history', + standalone: true, + imports: [ + TranslocoDirective, + VirtualScrollerModule, + UtcToLocalTimePipe, + LoadingComponent, + DefaultValuePipe, + NgxDatatableModule + ], + templateUrl: './email-history.component.html', + styleUrl: './email-history.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EmailHistoryComponent implements OnInit { + private readonly cdRef = inject(ChangeDetectorRef); + private readonly emailService = inject(EmailService) + + isLoading = true; + data: Array = []; + + ngOnInit() { + this.emailService.getEmailHistory().subscribe(data => { + this.data = data; + this.isLoading = false; + this.cdRef.markForCheck(); + }); + } + + protected readonly ColumnMode = ColumnMode; +} diff --git a/UI/Web/src/app/admin/license/license.component.html b/UI/Web/src/app/admin/license/license.component.html index e028d5b05..9cfb5a27a 100644 --- a/UI/Web/src/app/admin/license/license.component.html +++ b/UI/Web/src/app/admin/license/license.component.html @@ -1,15 +1,21 @@ -

{{t('kavita+-desc-part-1')}} {{t('kavita+-desc-part-2')}} {{t('kavita+-desc-part-3')}} FAQ

-

{{t('kavita+-requirement')}} {{t('kavita+-releases')}}

+ + + +
+

{{t('kavita+-desc-part-1')}} {{t('kavita+-desc-part-2')}} {{t('kavita+-desc-part-3')}}

+
- + -
} @else { - @if (hasValidLicense) { + @if (licenseInfo?.isActive) { {{t('license-valid')}} @@ -35,6 +41,10 @@ } } + @if (!isChecking && hasLicense && !licenseInfo) { +
{{t('license-mismatch')}}
+ } + } @else { {{t('no-license-key')}} } @@ -54,7 +64,7 @@ {{t('help-label')}} - @if (formGroup.dirty || formGroup.touched) { + @if (formGroup.dirty || !formGroup.untouched) {
+ }
diff --git a/UI/Web/src/app/admin/license/license.component.scss b/UI/Web/src/app/admin/license/license.component.scss index caf8e16b3..1608c1062 100644 --- a/UI/Web/src/app/admin/license/license.component.scss +++ b/UI/Web/src/app/admin/license/license.component.scss @@ -5,3 +5,13 @@ .successful-validation { color: var(--primary-color); } + +.custom-position { + right: 15px; + top: -42px; +} + +.custom-position-2 { + right: 160px; + top: -42px; +} diff --git a/UI/Web/src/app/admin/license/license.component.ts b/UI/Web/src/app/admin/license/license.component.ts index 46429c7f9..0ab987c32 100644 --- a/UI/Web/src/app/admin/license/license.component.ts +++ b/UI/Web/src/app/admin/license/license.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, inject, + Component, DestroyRef, inject, OnInit } from '@angular/core'; import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms"; @@ -13,8 +13,15 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import {environment} from "../../../environments/environment"; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {WikiLink} from "../../_models/wiki"; -import {RouterLink} from "@angular/router"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {DecimalPipe} from "@angular/common"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {switchMap} from "rxjs"; +import {LicenseInfo} from "../../_models/kavitaplus/license-info"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {filter, tap} from "rxjs/operators"; +import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; +import {LicenseService} from "../../_services/license.service"; @Component({ selector: 'app-license', @@ -22,50 +29,66 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett styleUrls: ['./license.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgbTooltip, LoadingComponent, ReactiveFormsModule, TranslocoDirective, RouterLink, SettingItemComponent] + imports: [NgbTooltip, LoadingComponent, ReactiveFormsModule, TranslocoDirective, SettingItemComponent, + DefaultValuePipe, UtcToLocalTimePipe, SettingButtonComponent, DecimalPipe] }) export class LicenseComponent implements OnInit { private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); private readonly toastr = inject(ToastrService); private readonly confirmService = inject(ConfirmService); protected readonly accountService = inject(AccountService); + protected readonly licenseService = inject(LicenseService); protected readonly WikiLink = WikiLink; formGroup: FormGroup = new FormGroup({}); isViewMode: boolean = true; - - hasValidLicense: boolean = false; - hasLicense: boolean = false; isChecking: boolean = true; isSaving: boolean = false; + + + hasLicense: boolean = false; + licenseInfo: LicenseInfo | null = null; + showEmail: boolean = false; + buyLink = environment.buyLink; manageLink = environment.manageLink; - - ngOnInit(): void { this.formGroup.addControl('licenseKey', new FormControl('', [Validators.required])); this.formGroup.addControl('email', new FormControl('', [Validators.required])); this.formGroup.addControl('discordId', new FormControl('', [Validators.pattern(/\d+/)])); + this.loadLicenseInfo().subscribe(); + + } + + loadLicenseInfo(forceCheck = false) { this.isChecking = true; this.cdRef.markForCheck(); - this.accountService.hasAnyLicense().subscribe(res => { - this.hasLicense = res; - this.cdRef.markForCheck(); - - if (this.hasLicense) { - this.accountService.hasValidLicense().subscribe(res => { - this.hasValidLicense = res; + return this.licenseService.hasAnyLicense() + .pipe( + tap(res => { + this.hasLicense = res; this.isChecking = false; this.cdRef.markForCheck(); - }); - } - }); + }), + filter(hasLicense => hasLicense), + tap(_ => { + this.isChecking = true; + this.cdRef.markForCheck(); + }), + switchMap(_ => this.licenseService.licenseInfo(forceCheck)), + tap(licenseInfo => { + this.licenseInfo = licenseInfo; + this.isChecking = false; + this.cdRef.markForCheck(); + }) + ); } @@ -79,30 +102,75 @@ export class LicenseComponent implements OnInit { saveForm() { this.isSaving = true; this.cdRef.markForCheck(); - this.accountService.updateUserLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim(), this.formGroup.get('discordId')!.value.trim()) + const hadActiveLicenseBefore = this.licenseInfo?.isActive; + this.licenseService.updateUserLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim(), this.formGroup.get('discordId')!.value.trim()) .subscribe(() => { - this.accountService.hasValidLicense(true).subscribe(isValid => { - this.hasValidLicense = isValid; - if (!this.hasValidLicense) { - this.toastr.info(translate('toasts.k+-license-saved')); - } else { - this.toastr.success(translate('toasts.k+-unlocked')); - } - this.hasLicense = this.formGroup.get('licenseKey')!.value.length > 0; + this.resetForm(); this.isViewMode = true; this.isSaving = false; this.cdRef.markForCheck(); + this.loadLicenseInfo().subscribe(async (info) => { + if (info?.isActive && !hadActiveLicenseBefore) { + await this.confirmService.info(translate('license.k+-unlocked-description'), translate('license.k+-unlocked')); + } else { + this.toastr.info(translate('toasts.k+-license-saved')); + } + }); + }, async (err) => { + await this.handleError(err); }); - }, err => { - this.isSaving = false; - this.cdRef.markForCheck(); - if (err.hasOwnProperty('error')) { - this.toastr.error(JSON.parse(err['error'])); + } + + private async handleError(err: any) { + this.isSaving = false; + this.cdRef.markForCheck(); + + if (err.hasOwnProperty('error')) { + if (err['error'][0] === '{') { + this.toastr.error(JSON.parse(err['error'])); + } else { + // Prompt user if they want to override their instance. This will call the rest flow then the register flow + if (err['error'] === 'Kavita instance already registered with another license') { + const answer = await this.confirmService.confirm(translate('license.k+-license-overwrite'), { + _type: 'confirm', + content: translate('license.k+-license-overwrite'), + disableEscape: false, + header: translate('license.k+-already-registered-header'), + buttons: [ + { + text: translate('license.overwrite'), + type: 'primary' + }, + { + text: translate('license.cancel'), + type: 'secondary' + }, + ] + }); + if (answer) { + this.forceSave(); + return; + } + return; } else { - this.toastr.error(translate('toasts.k+-error')); + } - }); + this.toastr.error(err['error']); + } + } else { + this.toastr.error(translate('toasts.k+-error')); + } + } + + forceSave() { + this.isSaving = false; + this.cdRef.markForCheck(); + + this.licenseService.resetLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim()) + .subscribe(_ => { + this.saveForm(); + }); } async deleteLicense() { @@ -110,10 +178,13 @@ export class LicenseComponent implements OnInit { return; } - this.accountService.deleteLicense().subscribe(() => { + this.licenseService.deleteLicense().subscribe(() => { this.resetForm(); - this.toggleViewMode(); - this.validateLicense(); + this.isViewMode = true; + this.licenseInfo = null; + this.hasLicense = false; + //this.hasValidLicense = false; + this.cdRef.markForCheck(); }); } @@ -122,28 +193,44 @@ export class LicenseComponent implements OnInit { return; } - this.accountService.resetLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim()).subscribe(() => { + this.licenseService.resetLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim()).subscribe(() => { this.toastr.success(translate('toasts.k+-reset-key-success')); }); } + // + // validateLicense(forceCheck = false) { + // return of().pipe( + // startWith(null), + // tap(_ => { + // this.isChecking = true; + // this.cdRef.markForCheck(); + // }), + // switchMap(_ => this.licenseService.licenseInfo(forceCheck)), + // tap(licenseInfo => { + // this.licenseInfo = licenseInfo; + // //this.hasValidLicense = licenseInfo?.isActive || false; + // this.isChecking = false; + // this.cdRef.markForCheck(); + // }) + // ) + // + // } + + updateEditMode(mode: boolean) { + this.isViewMode = !mode; + this.cdRef.markForCheck(); + } toggleViewMode() { this.isViewMode = !this.isViewMode; + console.log('edit mode: ', !this.isViewMode) + this.cdRef.markForCheck(); this.resetForm(); } - validateLicense() { - this.isChecking = true; - this.accountService.hasValidLicense(true).subscribe(res => { - this.hasValidLicense = res; - this.isChecking = false; - this.cdRef.markForCheck(); - }); - } - - updateEditMode(mode: boolean) { - this.isViewMode = mode; + toggleEmailShow() { + this.showEmail = !this.showEmail; this.cdRef.markForCheck(); } } diff --git a/UI/Web/src/app/admin/manage-kavitaplus/manage-kavitaplus.component.html b/UI/Web/src/app/admin/manage-kavitaplus/manage-kavitaplus.component.html deleted file mode 100644 index 2c5b71782..000000000 --- a/UI/Web/src/app/admin/manage-kavitaplus/manage-kavitaplus.component.html +++ /dev/null @@ -1,7 +0,0 @@ - - -@if (accountService.hasValidLicense$ | async) { -
- -
-} diff --git a/UI/Web/src/app/admin/manage-kavitaplus/manage-kavitaplus.component.ts b/UI/Web/src/app/admin/manage-kavitaplus/manage-kavitaplus.component.ts deleted file mode 100644 index b26213e88..000000000 --- a/UI/Web/src/app/admin/manage-kavitaplus/manage-kavitaplus.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; -import {AsyncPipe} from "@angular/common"; -import { - KavitaplusMetadataBreakdownStatsComponent -} from "../../statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component"; -import {LicenseComponent} from "../license/license.component"; -import {AccountService} from "../../_services/account.service"; - -@Component({ - selector: 'app-manage-kavitaplus', - standalone: true, - imports: [ - AsyncPipe, - KavitaplusMetadataBreakdownStatsComponent, - LicenseComponent - ], - templateUrl: './manage-kavitaplus.component.html', - styleUrl: './manage-kavitaplus.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class ManageKavitaplusComponent { - protected readonly accountService = inject(AccountService); -} diff --git a/UI/Web/src/app/admin/manage-logs/manage-logs.component.html b/UI/Web/src/app/admin/manage-logs/manage-logs.component.html index 5c022dd00..cb6c92a46 100644 --- a/UI/Web/src/app/admin/manage-logs/manage-logs.component.html +++ b/UI/Web/src/app/admin/manage-logs/manage-logs.component.html @@ -1,9 +1,11 @@ - - -
-
- {{item.timestamp | date}} [{{item.level}}] {{item.message}} -
+@if (logs$ | async; as items) { + +
+ @for (item of scroll.viewPortItems; track item.timestamp) { +
+ {{item.timestamp | date}} [{{item.level}}] {{item.message}}
- - \ No newline at end of file + } +
+
+} diff --git a/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts b/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts index 90a0cbf43..e46bb3d76 100644 --- a/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts +++ b/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts @@ -4,7 +4,7 @@ import { BehaviorSubject, take } from 'rxjs'; import { AccountService } from 'src/app/_services/account.service'; import { environment } from 'src/environments/environment'; import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; -import { NgIf, NgFor, AsyncPipe, DatePipe } from '@angular/common'; +import { AsyncPipe, DatePipe } from '@angular/common'; interface LogMessage { timestamp: string; @@ -18,7 +18,7 @@ interface LogMessage { templateUrl: './manage-logs.component.html', styleUrls: ['./manage-logs.component.scss'], standalone: true, - imports: [NgIf, VirtualScrollerModule, NgFor, AsyncPipe, DatePipe] + imports: [VirtualScrollerModule, AsyncPipe, DatePipe] }) export class ManageLogsComponent implements OnInit, OnDestroy { diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html new file mode 100644 index 000000000..162ef2700 --- /dev/null +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html @@ -0,0 +1,80 @@ + +

{{t('description')}}

+ +
+
+
+ + +
+
+
+ + + + + + {{t('series-name-header')}} + + + + {{item.series.name}} + + + + + + + {{t('status-header')}} + + + @if (item.series.isBlacklisted) { + {{t('blacklist-status-label')}} + } @else if (item.series.dontMatch) { + {{t('dont-match-status-label')}} + } @else { + @if (item.isMatched) { + {{t('matched-status-label')}} + + } @else { + {{t('unmatched-status-label')}} + } + } + + + + + + {{t('valid-until-header')}} + + + @if (item.series.isBlacklisted || item.series.dontMatch || !item.isMatched) { + {{null | defaultValue}} + } @else { + {{item.validUntilUtc | utcToLocalTime}} + } + + + + + + + + + + + + +
diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.scss b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.scss new file mode 100644 index 000000000..1e6162074 --- /dev/null +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.scss @@ -0,0 +1,9 @@ +.table { + min-height: 60px; + width: 100%; + +} + +.tr { + height: 60px; +} diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts new file mode 100644 index 000000000..6f1cf7a5a --- /dev/null +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts @@ -0,0 +1,128 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; +import {LicenseService} from "../../_services/license.service"; +import {Router} from "@angular/router"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {ImageComponent} from "../../shared/image/image.component"; +import {ImageService} from "../../_services/image.service"; +import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; +import {Series} from "../../_models/series"; +import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; +import {ActionService} from "../../_services/action.service"; +import {ManageService} from "../../_services/manage.service"; +import {ManageMatchSeries} from "../../_models/kavitaplus/manage-match-series"; +import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; +import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {Select2Module} from "ng-select2-component"; +import {ManageMatchFilter} from "../../_models/kavitaplus/manage-match-filter"; +import {allMatchStates, MatchStateOption} from "../../_models/kavitaplus/match-state-option"; +import {MatchStateOptionPipe} from "../../_pipes/match-state.pipe"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {debounceTime, distinctUntilChanged, switchMap, tap} from "rxjs"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter"; +import {ScrobbleEventType} from "../../_models/scrobbling/scrobble-event"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; + +@Component({ + selector: 'app-manage-matched-metadata', + standalone: true, + imports: [ + TranslocoDirective, + ImageComponent, + CardActionablesComponent, + VirtualScrollerModule, + ReactiveFormsModule, + Select2Module, + MatchStateOptionPipe, + UtcToLocalTimePipe, + DefaultValuePipe, + NgxDatatableModule, + ], + templateUrl: './manage-matched-metadata.component.html', + styleUrl: './manage-matched-metadata.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ManageMatchedMetadataComponent implements OnInit { + protected readonly MatchState = MatchStateOption; + protected readonly allMatchStates = allMatchStates.filter(m => m !== MatchStateOption.Matched); // Matched will have too many + + private readonly licenseService = inject(LicenseService); + private readonly actionFactory = inject(ActionFactoryService); + private readonly actionService = inject(ActionService); + private readonly router = inject(Router); + private readonly manageService = inject(ManageService); + private readonly cdRef = inject(ChangeDetectorRef); + protected readonly imageService = inject(ImageService); + + + isLoading: boolean = true; + data: Array = []; + actions: Array> = this.actionFactory.getSeriesActions(this.fixMatch.bind(this)) + .filter(item => item.action === Action.Match); + filterGroup = new FormGroup({ + 'matchState': new FormControl(MatchStateOption.Error, []), + }); + + ngOnInit() { + this.licenseService.hasValidLicense$.subscribe(license => { + if (!license) { + // Navigate home + this.router.navigate(['/']); + return; + } + + this.filterGroup.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged(), + tap(_ => { + this.isLoading = true; + this.cdRef.markForCheck(); + }), + switchMap(_ => this.loadData()), + tap(_ => { + this.isLoading = false; + this.cdRef.markForCheck(); + }), + ).subscribe(); + + this.loadData().subscribe(); + + }); + } + + + loadData() { + const filter: ManageMatchFilter = { + matchStateOption: parseInt(this.filterGroup.get('matchState')!.value + '', 10), + searchTerm: '' + }; + + this.isLoading = true; + this.data = []; + this.cdRef.markForCheck(); + + return this.manageService.getAllKavitaPlusSeries(filter).pipe(tap(data => { + this.data = [...data]; + this.isLoading = false; + this.cdRef.markForCheck(); + })); + } + + performAction(action: ActionItem, series: Series) { + if (action.callback) { + action.callback(action, series); + } + } + + fixMatch(actionItem: ActionItem, series: Series) { + this.actionService.matchSeries(series, result => { + if (!result) return; + this.loadData().subscribe(); + }); + } + + protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber; + protected readonly ScrobbleEventType = ScrobbleEventType; + protected readonly SpecialVolumeNumber = SpecialVolumeNumber; + protected readonly ColumnMode = ColumnMode; +} diff --git a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.html b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.html index 35e50c151..59f135682 100644 --- a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.html +++ b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.html @@ -12,42 +12,41 @@
-
- - - - - - - - - - @for(item of data | filter: filterList; track item.filePath; let index = $index) { - - - - - - } @empty { - @if (isLoading) { - - } @else { - - } - } - -
- {{t('file-header')}} - - {{t('comment-header')}} - - {{t('created-header')}} -
- {{item.filePath}} - - {{item.comment}} - - {{item.createdUtc | utcToLocalTime | defaultDate}} -
{{t('no-data')}}
-
+ + + + + {{t('file-header')}} + + + {{item.filePath}} + + + + + + + {{t('comment-header')}} + + + {{item.comment}} + + + + + + {{t('created-header')}} + + + {{item.createdUtc | utcToLocalTime | defaultDate}} + + +
diff --git a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts index 4ca6358e1..2ff70e7a7 100644 --- a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts +++ b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts @@ -19,10 +19,11 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { FilterPipe } from '../../_pipes/filter.pipe'; import { LoadingComponent } from '../../shared/loading/loading.component'; -import {TranslocoDirective} from "@jsverse/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; import {WikiLink} from "../../_models/wiki"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; @Component({ selector: 'app-manage-media-issues', @@ -30,7 +31,7 @@ import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; styleUrls: ['./manage-media-issues.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [ReactiveFormsModule, LoadingComponent, FilterPipe, SortableHeader, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe] + imports: [ReactiveFormsModule, LoadingComponent, FilterPipe, SortableHeader, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule] }) export class ManageMediaIssuesComponent implements OnInit { @@ -100,4 +101,6 @@ export class ManageMediaIssuesComponent implements OnInit { const query = (this.formGroup.get('filter')?.value || '').toLowerCase(); return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.filePath.toLowerCase().indexOf(query) >= 0 || listItem.details.indexOf(query) >= 0; } + protected readonly ColumnMode = ColumnMode; + protected readonly translate = translate; } diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html index 80e15f5cf..b2e8db72d 100644 --- a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html +++ b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html @@ -13,53 +13,55 @@ - - - - - - - - - - - @if (isLoading) { - - } @else { - @if(data | filter: filterList; as filteredData) { - @for(item of filteredData; track item.seriesId; let i = $index) { - - - - - - - } - @empty { - - } - } - } - -
- {{t('series-header')}} - - {{t('created-header')}} - - {{t('comment-header')}} - - {{t('edit-header')}} -
- {{item.details}} - - {{item.createdUtc | utcToLocalTime | defaultValue }} - - {{item.comment}} - - -
{{t('no-data')}}
+ + + + + + {{t('series-header')}} + + + {{item.details}} + + + + + + + {{t('created-header')}} + + + {{item.createdUtc | utcToLocalTime | defaultValue }} + + + + + + {{t('comment-header')}} + + + {{item.comment}} + + + + + + {{t('edit-header')}} + + + + + + diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts index efdcc76c7..4bd52b365 100644 --- a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts +++ b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts @@ -30,11 +30,13 @@ import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {DefaultModalOptions} from "../../_models/default-modal-options"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {DevicePlatformPipe} from "../../_pipes/device-platform.pipe"; @Component({ selector: 'app-manage-scrobble-errors', standalone: true, - imports: [ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader, TranslocoModule, DefaultDatePipe, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe], + imports: [ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader, TranslocoModule, DefaultDatePipe, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, DevicePlatformPipe, NgxDatatableModule], templateUrl: './manage-scrobble-errors.component.html', styleUrls: ['./manage-scrobble-errors.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -118,4 +120,5 @@ export class ManageScrobbleErrorsComponent implements OnInit { } protected readonly filter = filter; + protected readonly ColumnMode = ColumnMode; } diff --git a/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.html b/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.html index 851090528..ce662e6b0 100644 --- a/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.html +++ b/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.html @@ -1,9 +1,5 @@ -
-
- -
@if(accountService.isAdmin$ | async) {
diff --git a/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.ts b/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.ts index 60d632811..f1c9b0d50 100644 --- a/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.ts +++ b/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.ts @@ -2,8 +2,6 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, injec import {ManageScrobbleErrorsComponent} from "../manage-scrobble-errors/manage-scrobble-errors.component"; import {AsyncPipe} from "@angular/common"; import {AccountService} from "../../_services/account.service"; -import {map, shareReplay} from "rxjs"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ScrobblingHoldsComponent} from "../../user-settings/user-holds/scrobbling-holds.component"; import { UserScrobbleHistoryComponent @@ -15,7 +13,6 @@ import { imports: [ ManageScrobbleErrorsComponent, AsyncPipe, - ScrobblingHoldsComponent, UserScrobbleHistoryComponent ], templateUrl: './manage-scrobbling.component.html', diff --git a/UI/Web/src/app/admin/manage-system/manage-system.component.html b/UI/Web/src/app/admin/manage-system/manage-system.component.html index aabe99ff9..c0a613d8e 100644 --- a/UI/Web/src/app/admin/manage-system/manage-system.component.html +++ b/UI/Web/src/app/admin/manage-system/manage-system.component.html @@ -1,22 +1,37 @@
-
-

{{t('title')}}

-
-
{{t('version-title')}}
-
{{serverInfo.kavitaVersion}}
+ @if (serverInfo) { +
+

{{t('title')}}

-
{{t('installId-title')}}
-
{{serverInfo.installId}}
+
+
+
{{t('version-title')}}
+
{{serverInfo.kavitaVersion}}
+
-
{{t('first-install-version-title')}}
-
{{serverInfo.firstInstallVersion | defaultValue}}
+
+
{{t('installId-title')}}
+
{{serverInfo.installId}}
+
+
-
{{t('first-install-date-title')}}
-
{{serverInfo.firstInstallDate | date:'shortDate'}}
-
-
+
+
+
{{t('first-install-version-title')}}
+
{{serverInfo.firstInstallVersion}}
+
+ +
+
{{t('first-install-date-title')}}
+
{{serverInfo.firstInstallDate | date:'shortDate'}}
+
+
+
+ +
+ }

{{t('more-info-title')}}

@@ -50,6 +65,8 @@
+
+

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

diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html index 28c2dd867..f404efa87 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html @@ -1,178 +1,194 @@
-
+ @if (serverSettings) { + -

{{t('title')}}

- -
- @if (settingsForm.get('taskScan'); as formControl) { - - - @if (formControl.value === customOption) { - {{t(formControl.value)}} ({{settingsForm.get('taskScanCustom')?.value}}) - } @else { - {{t(formControl.value)}} - } - - - - + + - @if (formControl.value === customOption) { -
- - - - @if (settingsForm.dirty || !settingsForm.untouched) { -
- @if(settingsForm.get('taskScanCustom')?.errors?.required) { -
{{t('required')}}
- } - @if(settingsForm.get('taskScanCustom')?.errors?.invalidCron) { -
{{t('cron-notation')}}
- } -
+ -
- @if (settingsForm.get('taskBackup'); as formControl) { - - - @if (formControl.value === customOption) { - {{t(formControl.value)}} ({{settingsForm.get('taskBackupCustom')?.value}}) - } @else { - {{t(formControl.value)}} - } - - + @if (formControl.value === customOption) { +
+ + - + + + } +
- @if (formControl.value === customOption) { -
- - - - @if (settingsForm.dirty || !settingsForm.untouched) { -
- @if(settingsForm.get('taskBackupCustom')?.errors?.required) { -
{{t('required')}}
- } - @if(settingsForm.get('taskBackupCustom')?.errors?.invalidCron) { -
{{t('cron-notation')}}
- } -
- } -
- } -
-
- } -
- - -
- @if (settingsForm.get('taskCleanup'); as formControl) { - - - @if (formControl.value === customOption) { - {{t(formControl.value)}} ({{settingsForm.get('taskCleanupCustom')?.value}}) - } @else { - {{t(formControl.value)}} - } - - - - + + - @if (formControl.value === customOption) { -
- - - - @if (settingsForm.get('taskCleanupCustom')?.invalid) { -
- @if(settingsForm.get('taskCleanupCustom')?.errors?.required) { -
{{t('required')}}
- } - @if(settingsForm.get('taskCleanupCustom')?.errors?.invalidCron) { -
{{t('cron-notation')}}
- } -
+ -
+ @if (formControl.value === customOption) { +
+ + -

{{t('adhoc-tasks-title')}}

+ @if (settingsForm.dirty || !settingsForm.untouched) { +
+ @if(settingsForm.get('taskBackupCustom')?.errors?.required) { +
{{t('required')}}
+ } + @if(settingsForm.get('taskBackupCustom')?.errors?.invalidCron) { +
{{t('cron-notation')}}
+ } +
+ } +
+ } + + + } +
- @for(task of adhocTasks; track task.name; let idx = $index) { -
- - - -
- } -
+
+ @if (settingsForm.get('taskCleanup'); as formControl) { + + + @if (formControl.value === customOption) { + {{t(formControl.value)}} ({{settingsForm.get('taskCleanupCustom')?.value}}) + } @else { + {{t(formControl.value)}} + } + + -

{{t('recurring-tasks-title')}}

- - - - - - - - - - @for(task of recurringTasks$ | async; track task; let idx = $index) { - - - - - - } - -
{{t('job-title-header')}}{{t('last-executed-header')}}{{t('cron-header')}}
- {{task.title | titlecase}} - - {{task.lastExecutionUtc | utcToLocalTime | defaultValue }} - {{task.cron}}
- + + + @if (formControl.value === customOption) { +
+ + + + @if (settingsForm.get('taskCleanupCustom')?.invalid) { +
+ @if(settingsForm.get('taskCleanupCustom')?.errors?.required) { +
{{t('required')}}
+ } + @if(settingsForm.get('taskCleanupCustom')?.errors?.invalidCron) { +
{{t('cron-notation')}}
+ } +
+ } +
+ } +
+
+ } +
+ + +
+ +

{{t('adhoc-tasks-title')}}

+ + @for(task of adhocTasks; track task.name; let idx = $index) { +
+ + + +
+ } + +
+ +

{{t('recurring-tasks-title')}}

+ + + + + {{t('job-title-header')}} + + + {{item.title | titlecase}} + + + + + + + {{t('last-executed-header')}} + + + {{item.lastExecutionUtc | utcToLocalTime | defaultValue }} + + + + + + {{t('cron-header')}} + + + {{item.cron}} + + + + + }
- diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index acfbe89f4..c7b6c050a 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -3,15 +3,15 @@ import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/ import {ToastrService} from 'ngx-toastr'; import {SettingsService} from '../settings.service'; import {ServerSettings} from '../_models/server-settings'; -import {shareReplay, take} from 'rxjs/operators'; +import {shareReplay} from 'rxjs/operators'; import {debounceTime, defer, distinctUntilChanged, filter, forkJoin, Observable, of, switchMap, tap} from 'rxjs'; import {ServerService} from 'src/app/_services/server.service'; import {Job} from 'src/app/_models/job/job'; import {UpdateNotificationModalComponent} from 'src/app/shared/update-notification/update-notification-modal.component'; -import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {DownloadService} from 'src/app/shared/_services/download.service'; import {DefaultValuePipe} from '../../_pipes/default-value.pipe'; -import {AsyncPipe, DatePipe, NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; +import {AsyncPipe, TitleCasePipe} from '@angular/common'; import {translate, TranslocoModule} from "@jsverse/transloco"; import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; @@ -21,6 +21,7 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett import {ConfirmService} from "../../shared/confirm.service"; import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; import {DefaultModalOptions} from "../../_models/default-modal-options"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; interface AdhocTask { name: string; @@ -36,14 +37,13 @@ interface AdhocTask { styleUrls: ['./manage-tasks-settings.component.scss'], standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe, - TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, SettingButtonComponent] + imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe, + TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, SettingButtonComponent, NgxDatatableModule] }) export class ManageTasksSettingsComponent implements OnInit { private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); - private readonly confirmService = inject(ConfirmService); private readonly settingsService = inject(SettingsService); private readonly toastr = inject(ToastrService); private readonly serverService = inject(ServerService); @@ -323,4 +323,5 @@ export class ManageTasksSettingsComponent implements OnInit { } + protected readonly ColumnMode = ColumnMode; } diff --git a/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.html b/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.html new file mode 100644 index 000000000..9f5c83d02 --- /dev/null +++ b/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.html @@ -0,0 +1,52 @@ + +
+

{{t('description')}}

+ + + + + + + {{t('username-header')}} + + + {{item.username}} + + + + + + + {{t('anilist-header')}} + + + @if (item.isAniListTokenSet) { + {{t('token-set-label')}} {{t('expires-label', {date: item.aniListValidUntilUtc | utcToLocalTime})}} + } @else { + {{null | defaultValue}} + } + + + + + + {{t('mal-header')}} + + + @if (item.isMalTokenSet) { + {{t('token-set-label')}} + } @else { + {{null | defaultValue}} + } + + + + +
+
diff --git a/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.scss b/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.ts b/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.ts new file mode 100644 index 000000000..9de05e0eb --- /dev/null +++ b/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.ts @@ -0,0 +1,58 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; +import {TranslocoDirective} from "@jsverse/transloco"; +import {MemberService} from "../../_services/member.service"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {LoadingComponent} from "../../shared/loading/loading.component"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; +import {UserTokenInfo} from "../../_models/kavitaplus/user-token-info"; +import {ServerService} from "../../_services/server.service"; +import {SettingsService} from "../settings.service"; +import {MessageHubService} from "../../_services/message-hub.service"; +import {ConfirmService} from "../../shared/confirm.service"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; +import {ImageComponent} from "../../shared/image/image.component"; + +@Component({ + selector: 'app-manage-user-tokens', + standalone: true, + imports: [ + TranslocoDirective, + DefaultValuePipe, + LoadingComponent, + UtcToLocalTimePipe, + VirtualScrollerModule, + CardActionablesComponent, + ImageComponent, + NgxDatatableModule + ], + templateUrl: './manage-user-tokens.component.html', + styleUrl: './manage-user-tokens.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ManageUserTokensComponent implements OnInit { + + private readonly cdRef = inject(ChangeDetectorRef); + private readonly memberService = inject(MemberService); + + isLoading = true; + users: UserTokenInfo[] = []; + + ngOnInit() { + this.loadData(); + } + + loadData() { + this.isLoading = true; + this.cdRef.markForCheck(); + + this.memberService.getUserTokenInfo().subscribe(users => { + this.users = users; + this.isLoading = false; + this.cdRef.markForCheck(); + }); + } + + protected readonly ColumnMode = ColumnMode; +} diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.html b/UI/Web/src/app/admin/manage-users/manage-users.component.html index 56cf5e20a..018c9d56a 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.html +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.html @@ -5,6 +5,8 @@
+ + diff --git a/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html new file mode 100644 index 000000000..9367f9388 --- /dev/null +++ b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html @@ -0,0 +1,37 @@ + + @if (update) { +
+ @if (update.blogPart) { +
+ +
+ } + +
+ + + + + + + +
+ + @if (showExtras) { +
+ +
+ {{t('published-label')}}{{update.publishDate | date: 'short'}} + @if (!update.isDocker && (accountService.isAdmin$ | async)) { + @if (update.updateVersion === update.currentVersion) { + {{t('installed')}} + } @else { + {{t('download')}} + } + } +
+ } +
+ } +
+ diff --git a/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.scss b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.scss new file mode 100644 index 000000000..56a2d5278 --- /dev/null +++ b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.scss @@ -0,0 +1,13 @@ +.update-details { + border-radius: 0.5rem; +} + +.blog-content { + margin-bottom: 1.5rem; + line-height: 1.6; + word-wrap: break-word; + + img { + max-width: 100% !important; + } +} diff --git a/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.ts b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.ts new file mode 100644 index 000000000..4caeae127 --- /dev/null +++ b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.ts @@ -0,0 +1,30 @@ +import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; +import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe"; +import {UpdateSectionComponent} from "../update-section/update-section.component"; +import {AsyncPipe, DatePipe} from "@angular/common"; +import {UpdateVersionEvent} from "../../../_models/events/update-version-event"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {AccountService} from "../../../_services/account.service"; + +@Component({ + selector: 'app-changelog-update-item', + standalone: true, + imports: [ + SafeHtmlPipe, + UpdateSectionComponent, + AsyncPipe, + DatePipe, + TranslocoDirective + ], + templateUrl: './changelog-update-item.component.html', + styleUrl: './changelog-update-item.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ChangelogUpdateItemComponent { + + protected readonly accountService = inject(AccountService); + + @Input({required:true}) update: UpdateVersionEvent | null = null; + @Input() index: number = 0; + @Input() showExtras: boolean = true; +} diff --git a/UI/Web/src/app/announcements/_components/changelog/changelog.component.html b/UI/Web/src/app/announcements/_components/changelog/changelog.component.html index 6a526a46d..67b451754 100644 --- a/UI/Web/src/app/announcements/_components/changelog/changelog.component.html +++ b/UI/Web/src/app/announcements/_components/changelog/changelog.component.html @@ -1,37 +1,31 @@
-

- {{t('description', {installed: ''})}} - {{t('installed')}} - {{t('description-continued', {installed: ''})}} -

@for(update of updates; track update; let indx = $index) { -
-
-

{{update.updateTitle}}  - @if (update.isOnNightlyInRelease) { - {{t('nightly', {version: update.currentVersion})}} - } @else if (update.isReleaseEqual) { - {{t('installed')}} - } @else if (update.isReleaseNewer && indx === 0) { - {{t('available')}} - } -

-
{{t('published-label')}}{{update.publishDate | date: 'short'}}
- -
-              
-            
- @if (!update.isDocker && (accountService.isAdmin$ | async)) { - @if (update.updateVersion === update.currentVersion) { - {{t('installed')}} - } @else { - {{t('download')}} - } - } +
+
+

+ +

+
+
+ + + +
+
} diff --git a/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss b/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss index 2046a4625..b9434f558 100644 --- a/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss +++ b/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss @@ -1,25 +1,8 @@ -.update-body { - width: 100%; - word-wrap: break-word; - white-space: pre-wrap; +.update-details { + border-radius: 0.5rem; } - -::ng-deep .changelog { - - h1 { - font-size: 26px; - } - - p, ul { - margin-bottom: 0px; - } - - img { - max-width: 100% !important; - } - - +.changelog-header { + color: var(--body-text-color); } - diff --git a/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts b/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts index 2ac5212bc..c625ce1d4 100644 --- a/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts +++ b/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts @@ -2,17 +2,25 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} f import {UpdateVersionEvent} from 'src/app/_models/events/update-version-event'; import {ServerService} from 'src/app/_services/server.service'; import {LoadingComponent} from '../../../shared/loading/loading.component'; -import {ReadMoreComponent} from '../../../shared/read-more/read-more.component'; -import {AsyncPipe, DatePipe} from '@angular/common'; import {TranslocoDirective} from "@jsverse/transloco"; import {AccountService} from "../../../_services/account.service"; +import { + NgbAccordionBody, + NgbAccordionButton, NgbAccordionCollapse, + NgbAccordionDirective, + NgbAccordionHeader, + NgbAccordionItem +} from "@ng-bootstrap/ng-bootstrap"; +import {ChangelogUpdateItemComponent} from "../changelog-update-item/changelog-update-item.component"; + @Component({ selector: 'app-changelog', templateUrl: './changelog.component.html', styleUrls: ['./changelog.component.scss'], standalone: true, - imports: [ReadMoreComponent, LoadingComponent, DatePipe, TranslocoDirective, AsyncPipe], + imports: [LoadingComponent, TranslocoDirective, NgbAccordionDirective, + NgbAccordionItem, NgbAccordionButton, NgbAccordionHeader, NgbAccordionCollapse, NgbAccordionBody, ChangelogUpdateItemComponent], changeDetection: ChangeDetectionStrategy.OnPush }) export class ChangelogComponent implements OnInit { @@ -25,7 +33,7 @@ export class ChangelogComponent implements OnInit { isLoading: boolean = true; ngOnInit(): void { - this.serverService.getChangelog().subscribe(updates => { + this.serverService.getChangelog(10).subscribe(updates => { this.updates = updates; this.isLoading = false; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html new file mode 100644 index 000000000..3aa8b8131 --- /dev/null +++ b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html @@ -0,0 +1,16 @@ + + + + + + diff --git a/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.scss b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.ts b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.ts new file mode 100644 index 000000000..d1eb43285 --- /dev/null +++ b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.ts @@ -0,0 +1,61 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; +import {TranslocoDirective, TranslocoService} from "@jsverse/transloco"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {UpdateVersionEvent} from "../../../_models/events/update-version-event"; +import {UpdateSectionComponent} from "../update-section/update-section.component"; +import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe"; +import {VersionService} from "../../../_services/version.service"; +import {ChangelogUpdateItemComponent} from "../changelog-update-item/changelog-update-item.component"; + +@Component({ + selector: 'app-new-update-modal', + standalone: true, + imports: [ + TranslocoDirective, + UpdateSectionComponent, + SafeHtmlPipe, + ChangelogUpdateItemComponent + ], + templateUrl: './new-update-modal.component.html', + styleUrl: './new-update-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class NewUpdateModalComponent { + + private readonly ngbModal = inject(NgbActiveModal); + private readonly translocoService = inject(TranslocoService); + + @Input({required: true}) version: string = ''; + @Input({required: true}) update: UpdateVersionEvent | null = null; + + close() { + this.ngbModal.dismiss(); + } + + refresh() { + this.bustLocaleCache(); + this.applyUpdate(this.version); + // Refresh manually + location.reload(); + } + + private applyUpdate(version: string): void { + this.bustLocaleCache(); + console.log('Setting version key: ', version); + localStorage.setItem(VersionService.versionKey, version); + location.reload(); + } + + private bustLocaleCache() { + localStorage.removeItem('@transloco/translations/timestamp'); + localStorage.removeItem('@transloco/translations'); + localStorage.removeItem('translocoLang'); + const locale = localStorage.getItem('kavita-locale') || 'en'; + (this.translocoService as any).cache.delete(locale); + (this.translocoService as any).cache.clear(); + + // TODO: Retrigger transloco + this.translocoService.setActiveLang(locale); + } + +} diff --git a/UI/Web/src/app/announcements/_components/update-section/update-section.component.html b/UI/Web/src/app/announcements/_components/update-section/update-section.component.html new file mode 100644 index 000000000..45db97bac --- /dev/null +++ b/UI/Web/src/app/announcements/_components/update-section/update-section.component.html @@ -0,0 +1,10 @@ +@if (items.length > 0) { +
{{ title }} + {{items.length}} +
+
    + @for(item of items; track item;) { +
  • {{item}}
  • + } +
+} diff --git a/UI/Web/src/app/announcements/_components/update-section/update-section.component.scss b/UI/Web/src/app/announcements/_components/update-section/update-section.component.scss new file mode 100644 index 000000000..0e3b37fac --- /dev/null +++ b/UI/Web/src/app/announcements/_components/update-section/update-section.component.scss @@ -0,0 +1,2 @@ +.code { +} diff --git a/UI/Web/src/app/announcements/_components/update-section/update-section.component.ts b/UI/Web/src/app/announcements/_components/update-section/update-section.component.ts new file mode 100644 index 000000000..a411e6520 --- /dev/null +++ b/UI/Web/src/app/announcements/_components/update-section/update-section.component.ts @@ -0,0 +1,16 @@ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; + +@Component({ + selector: 'app-update-section', + standalone: true, + imports: [], + templateUrl: './update-section.component.html', + styleUrl: './update-section.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UpdateSectionComponent { + @Input({required: true}) items: Array = []; + @Input({required: true}) title: string = ''; + + // TODO: Implement a read-more-list so that we by default show a configurable number +} diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index ba9c46a49..4d511f85a 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -23,6 +23,9 @@ import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-m import {PreferenceNavComponent} from "./sidenav/preference-nav/preference-nav.component"; import {Breakpoint, UtilityService} from "./shared/_services/utility.service"; import {TranslocoService} from "@jsverse/transloco"; +import {User} from "./_models/user"; +import {VersionService} from "./_services/version.service"; +import {LicenseService} from "./_services/license.service"; @Component({ selector: 'app-root', @@ -48,13 +51,15 @@ export class AppComponent implements OnInit { private readonly themeService = inject(ThemeService); private readonly document = inject(DOCUMENT); private readonly translocoService = inject(TranslocoService); + private readonly versionService = inject(VersionService); // Needs to be injected to run background job + private readonly licenseService = inject(LicenseService); protected readonly Breakpoint = Breakpoint; constructor(ratingConfig: NgbRatingConfig, modalConfig: NgbModalConfig) { - modalConfig.fullscreen = 'md'; + modalConfig.fullscreen = 'lg'; // Setup default rating config ratingConfig.max = 5; @@ -119,46 +124,6 @@ export class AppComponent implements OnInit { // Bootstrap anything that's needed this.themeService.getThemes().subscribe(); this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe(); - - // Get the server version, compare vs localStorage, and if different bust locale cache - const versionKey = 'kavita--version'; - this.serverService.getVersion(user.apiKey).subscribe(version => { - const cachedVersion = localStorage.getItem(versionKey); - console.log('Kavita version: ', version, ' Running version: ', cachedVersion); - - if (cachedVersion == null || cachedVersion != version) { - // Bust locale cache - this.bustLocaleCache(); - localStorage.setItem(versionKey, version); - location.reload(); - } - localStorage.setItem(versionKey, version); - }); - - // Every hour, have the UI check for an update. People seriously stay out of date - interval(2* 60 * 60 * 1000) // 2 hours in milliseconds - .pipe( - switchMap(() => this.accountService.currentUser$), - filter(u => u !== undefined && this.accountService.hasAdminRole(u)), - switchMap(_ => this.serverService.checkHowOutOfDate()), - filter(versionOutOfDate => { - return !isNaN(versionOutOfDate) && versionOutOfDate > 2; - }), - tap(versionOutOfDate => { - if (!this.ngbModal.hasOpenModals()) { - const ref = this.ngbModal.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'}); - ref.componentInstance.versionsOutOfDate = versionOutOfDate; - } - }) - ) - .subscribe(); - } - - private bustLocaleCache() { - localStorage.removeItem('@transloco/translations/timestamp'); - localStorage.removeItem('@transloco/translations'); - localStorage.removeItem('translocoLang'); - (this.translocoService as any).cache.delete(localStorage.getItem('kavita-locale') || 'en'); - (this.translocoService as any).cache.clear(); + this.licenseService.licenseInfo().subscribe(); } } diff --git a/UI/Web/src/app/book-reader/_models/book-paper-theme.ts b/UI/Web/src/app/book-reader/_models/book-paper-theme.ts index 69a6d4e5d..41c7958e1 100644 --- a/UI/Web/src/app/book-reader/_models/book-paper-theme.ts +++ b/UI/Web/src/app/book-reader/_models/book-paper-theme.ts @@ -32,63 +32,63 @@ export const BookPaperTheme = ` --accordion-active-body-bg-color: #F1E4D5; /* Buttons */ - --btn-focus-boxshadow-color: rgb(255 255 255 / 50%); - --btn-primary-text-color: white; - --btn-primary-bg-color: var(--primary-color); - --btn-primary-border-color: var(--primary-color); - --btn-primary-hover-text-color: white; - --btn-primary-hover-bg-color: var(--primary-color-darker-shade); - --btn-primary-hover-border-color: var(--primary-color-darker-shade); - --btn-alt-bg-color: #424c72; - --btn-alt-border-color: #444f75; - --btn-alt-hover-bg-color: #3b4466; - --btn-alt-focus-bg-color: #343c59; - --btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%); - --btn-fa-icon-color: black; - --btn-disabled-bg-color: #343a40; - --btn-disabled-text-color: #efefef; - --btn-disabled-border-color: #6c757d; + --btn-focus-boxshadow-color: rgb(255 255 255 / 50%); + --btn-primary-text-color: white; + --btn-primary-bg-color: var(--primary-color); + --btn-primary-border-color: var(--primary-color); + --btn-primary-hover-text-color: white; + --btn-primary-hover-bg-color: var(--primary-color-darker-shade); + --btn-primary-hover-border-color: var(--primary-color-darker-shade); + --btn-alt-bg-color: #424c72; + --btn-alt-border-color: #444f75; + --btn-alt-hover-bg-color: #3b4466; + --btn-alt-focus-bg-color: #343c59; + --btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%); + --btn-fa-icon-color: black; + --btn-disabled-bg-color: #343a40; + --btn-disabled-text-color: #efefef; + --btn-disabled-border-color: #6c757d; - /* Inputs */ - --input-bg-color: white; - --input-bg-readonly-color: #F1E4D5; - --input-focused-border-color: #ccc; - --input-placeholder-color: black; - --input-border-color: #ccc; - --input-text-color: black; - --input-focus-boxshadow-color: rgb(255 255 255 / 50%); + /* Inputs */ + --input-bg-color: white; + --input-bg-readonly-color: #F1E4D5; + --input-focused-border-color: #ccc; + --input-placeholder-color: black; + --input-border-color: #ccc; + --input-text-color: black; + --input-focus-boxshadow-color: rgb(255 255 255 / 50%); - /* Nav (Tabs) */ - --nav-tab-border-color: rgba(44, 118, 88, 0.7); - --nav-tab-text-color: var(--body-text-color); - --nav-tab-bg-color: var(--primary-color); - --nav-tab-hover-border-color: var(--primary-color); - --nav-tab-active-text-color: white; - --nav-tab-border-hover-color: transparent; - --nav-tab-hover-text-color: var(--body-text-color); - --nav-tab-hover-bg-color: transparent; - --nav-tab-border-top: rgba(44, 118, 88, 0.7); - --nav-tab-border-left: rgba(44, 118, 88, 0.7); - --nav-tab-border-bottom: rgba(44, 118, 88, 0.7); - --nav-tab-border-right: rgba(44, 118, 88, 0.7); - --nav-tab-hover-border-top: rgba(44, 118, 88, 0.7); - --nav-tab-hover-border-left: rgba(44, 118, 88, 0.7); - --nav-tab-hover-border-bottom: var(--bs-body-bg); - --nav-tab-hover-border-right: rgba(44, 118, 88, 0.7); - --nav-tab-active-hover-bg-color: var(--primary-color); - --nav-link-bg-color: var(--primary-color); - --nav-link-active-text-color: white; - --nav-link-text-color: white; + /* Nav (Tabs) */ + --nav-tab-border-color: rgba(44, 118, 88, 0.7); + --nav-tab-text-color: var(--body-text-color); + --nav-tab-bg-color: var(--primary-color); + --nav-tab-hover-border-color: var(--primary-color); + --nav-tab-active-text-color: white; + --nav-tab-border-hover-color: transparent; + --nav-tab-hover-text-color: var(--body-text-color); + --nav-tab-hover-bg-color: transparent; + --nav-tab-border-top: rgba(44, 118, 88, 0.7); + --nav-tab-border-left: rgba(44, 118, 88, 0.7); + --nav-tab-border-bottom: rgba(44, 118, 88, 0.7); + --nav-tab-border-right: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-top: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-left: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-bottom: var(--bs-body-bg); + --nav-tab-hover-border-right: rgba(44, 118, 88, 0.7); + --nav-tab-active-hover-bg-color: var(--primary-color); + --nav-link-bg-color: var(--primary-color); + --nav-link-active-text-color: white; + --nav-link-text-color: white; - /* Reading Bar */ - --br-actionbar-button-hover-border-color: #6c757d; - --br-actionbar-bg-color: #F1E4D5; + /* Reading Bar */ + --br-actionbar-button-hover-border-color: #6c757d; + --br-actionbar-bg-color: #F1E4D5; - /* Drawer */ - --drawer-pagination-horizontal-rule: inset 0 -1px 0 rgb(0 0 0 / 13%); + /* Drawer */ + --drawer-pagination-horizontal-rule: inset 0 -1px 0 rgb(0 0 0 / 13%); - /* Custom variables */ - --theme-bg-color: #fff3c9; + /* Custom variables */ + --theme-bg-color: #fff3c9; } .reader-container { @@ -115,9 +115,8 @@ export const BookPaperTheme = ` } .book-content img, .book-content img[src] { -z-index: 1; -filter: brightness(0.85) !important; -background-color: initial !important; + z-index: 1; + background-color: initial !important; } @@ -129,8 +128,12 @@ background-color: initial !important; color: #dcdcdc !important; } -.book-content :visited, .book-content :visited *, .book-content :visited *[class] {color: rgb(240, 50, 50) !important} -.book-content :link:not(cite), :link .book-content *:not(cite) {color: #00f !important} +.book-content :visited, .book-content :visited *, .book-content :visited *[class] { + color: rgb(240, 50, 50) !important; +} +.book-content :link:not(cite), :link .book-content *:not(cite) { + color: #00f !important; +} .btn-check:checked + .btn { color: white; diff --git a/UI/Web/src/app/book-reader/_models/book-white-theme.ts b/UI/Web/src/app/book-reader/_models/book-white-theme.ts index f25ab96a1..31c6cccec 100644 --- a/UI/Web/src/app/book-reader/_models/book-white-theme.ts +++ b/UI/Web/src/app/book-reader/_models/book-white-theme.ts @@ -35,64 +35,64 @@ export const BookWhiteTheme = ` --accordion-active-body-bg-color: white; /* Buttons */ - --btn-focus-boxshadow-color: rgb(255 255 255 / 50%); - --btn-primary-text-color: white; - --btn-primary-bg-color: var(--primary-color); - --btn-primary-border-color: var(--primary-color); - --btn-primary-hover-text-color: white; - --btn-primary-hover-bg-color: var(--primary-color-darker-shade); - --btn-primary-hover-border-color: var(--primary-color-darker-shade); - --btn-alt-bg-color: #424c72; - --btn-alt-border-color: #444f75; - --btn-alt-hover-bg-color: #3b4466; - --btn-alt-focus-bg-color: #343c59; - --btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%); - --btn-fa-icon-color: black; - --btn-disabled-bg-color: #343a40; - --btn-disabled-text-color: #efefef; - --btn-disabled-border-color: #6c757d; + --btn-focus-boxshadow-color: rgb(255 255 255 / 50%); + --btn-primary-text-color: white; + --btn-primary-bg-color: var(--primary-color); + --btn-primary-border-color: var(--primary-color); + --btn-primary-hover-text-color: white; + --btn-primary-hover-bg-color: var(--primary-color-darker-shade); + --btn-primary-hover-border-color: var(--primary-color-darker-shade); + --btn-alt-bg-color: #424c72; + --btn-alt-border-color: #444f75; + --btn-alt-hover-bg-color: #3b4466; + --btn-alt-focus-bg-color: #343c59; + --btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%); + --btn-fa-icon-color: black; + --btn-disabled-bg-color: #343a40; + --btn-disabled-text-color: #efefef; + --btn-disabled-border-color: #6c757d; - /* Inputs */ - --input-bg-color: white; - --input-bg-readonly-color: white; - --input-focused-border-color: #ccc; - --input-text-color: black; - --input-placeholder-color: black; - --input-border-color: #ccc; - --input-focus-boxshadow-color: rgb(255 255 255 / 50%); + /* Inputs */ + --input-bg-color: white; + --input-bg-readonly-color: white; + --input-focused-border-color: #ccc; + --input-text-color: black; + --input-placeholder-color: black; + --input-border-color: #ccc; + --input-focus-boxshadow-color: rgb(255 255 255 / 50%); - /* Nav (Tabs) */ - --nav-tab-border-color: rgba(44, 118, 88, 0.7); - --nav-tab-text-color: var(--body-text-color); - --nav-tab-bg-color: var(--primary-color); - --nav-tab-hover-border-color: var(--primary-color); - --nav-tab-active-text-color: white; - --nav-tab-border-hover-color: transparent; - --nav-tab-hover-text-color: var(--body-text-color); - --nav-tab-hover-bg-color: transparent; - --nav-tab-border-top: rgba(44, 118, 88, 0.7); - --nav-tab-border-left: rgba(44, 118, 88, 0.7); - --nav-tab-border-bottom: rgba(44, 118, 88, 0.7); - --nav-tab-border-right: rgba(44, 118, 88, 0.7); - --nav-tab-hover-border-top: rgba(44, 118, 88, 0.7); - --nav-tab-hover-border-left: rgba(44, 118, 88, 0.7); - --nav-tab-hover-border-bottom: var(--bs-body-bg); - --nav-tab-hover-border-right: rgba(44, 118, 88, 0.7); - --nav-tab-active-hover-bg-color: var(--primary-color); - --nav-link-bg-color: var(--primary-color); - --nav-link-active-text-color: white; - --nav-link-text-color: white; + /* Nav (Tabs) */ + --nav-tab-border-color: rgba(44, 118, 88, 0.7); + --nav-tab-text-color: var(--body-text-color); + --nav-tab-bg-color: var(--primary-color); + --nav-tab-hover-border-color: var(--primary-color); + --nav-tab-active-text-color: white; + --nav-tab-border-hover-color: transparent; + --nav-tab-hover-text-color: var(--body-text-color); + --nav-tab-hover-bg-color: transparent; + --nav-tab-border-top: rgba(44, 118, 88, 0.7); + --nav-tab-border-left: rgba(44, 118, 88, 0.7); + --nav-tab-border-bottom: rgba(44, 118, 88, 0.7); + --nav-tab-border-right: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-top: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-left: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-bottom: var(--bs-body-bg); + --nav-tab-hover-border-right: rgba(44, 118, 88, 0.7); + --nav-tab-active-hover-bg-color: var(--primary-color); + --nav-link-bg-color: var(--primary-color); + --nav-link-active-text-color: white; + --nav-link-text-color: white; - /* Reading Bar */ - --br-actionbar-button-text-color: black; - --br-actionbar-button-hover-border-color: #6c757d; - --br-actionbar-bg-color: white; + /* Reading Bar */ + --br-actionbar-button-text-color: black; + --br-actionbar-button-hover-border-color: #6c757d; + --br-actionbar-bg-color: white; - /* Drawer */ - --drawer-pagination-horizontal-rule: inset 0 -1px 0 rgb(0 0 0 / 13%); - --drawer-pagination-border: 1px solid rgb(0 0 0 / 13%); + /* Drawer */ + --drawer-pagination-horizontal-rule: inset 0 -1px 0 rgb(0 0 0 / 13%); + --drawer-pagination-border: 1px solid rgb(0 0 0 / 13%); } .reader-container { @@ -115,22 +115,25 @@ export const BookWhiteTheme = ` } .book-content img, .book-content img[src] { -z-index: 1; -filter: brightness(0.85) !important; -background-color: initial !important; + z-index: 1; + background-color: initial !important; } .book-content *:not(code), .book-content *:not(a) { - background-color: white; - box-shadow: none; - text-shadow: none; - border-radius: unset; - color: #dcdcdc !important; + background-color: white; + box-shadow: none; + text-shadow: none; + border-radius: unset; + color: #dcdcdc !important; } -.book-content :visited, .book-content :visited *, .book-content :visited *[class] {color: rgb(240, 50, 50) !important} -.book-content :link:not(cite), :link .book-content *:not(cite) {color: #00f !important} +.book-content :visited, .book-content :visited *, .book-content :visited *[class] { + color: rgb(240, 50, 50) !important; +} +.book-content :link:not(cite), :link .book-content *:not(cite) { + color: #00f !important; +} .btn-check:checked + .btn { color: white; diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index 14067952b..e304f5bcc 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -731,18 +731,6 @@
diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index 9adb23be6..e7d7723ea 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -62,6 +62,7 @@ import {ActionService} from "../../../_services/action.service"; import {DownloadService} from "../../../shared/_services/download.service"; import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component"; import {ReadTimePipe} from "../../../_pipes/read-time.pipe"; +import {LicenseService} from "../../../_services/license.service"; enum TabID { General = 0, @@ -134,6 +135,7 @@ export class EditSeriesModalComponent implements OnInit { private readonly metadataService = inject(MetadataService); private readonly cdRef = inject(ChangeDetectorRef); public readonly accountService = inject(AccountService); + protected readonly licenseService = inject(LicenseService); private readonly destroyRef = inject(DestroyRef); private readonly toastr = inject(ToastrService); private readonly actionFactoryService = inject(ActionFactoryService); diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 9a9b134af..e654f5f1d 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -45,7 +45,7 @@
- @if (showReadButton) { + @if (showReadButton && !bulkSelectionService.hasSelections()) {
diff --git a/UI/Web/src/app/cards/chapter-card/chapter-card.component.html b/UI/Web/src/app/cards/chapter-card/chapter-card.component.html index 1c2646981..8a5d55441 100644 --- a/UI/Web/src/app/cards/chapter-card/chapter-card.component.html +++ b/UI/Web/src/app/cards/chapter-card/chapter-card.component.html @@ -44,16 +44,19 @@ }
-
-
- - -
- -
-
+ + @if (!bulkSelectionService.hasSelections()) { +
+
+ + +
+ +
+
+
-
+ }
@if (chapter.isSpecial) { diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html index ee93cd608..026c612c3 100644 --- a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html +++ b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html @@ -1,4 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts index 19fbe32e9..b1d6c3b1a 100644 --- a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts +++ b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts @@ -9,6 +9,7 @@ import {TranslocoDirective} from "@jsverse/transloco"; import {FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; @Component({ selector: 'app-edit-chapter-progress', @@ -23,7 +24,8 @@ import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; ReactiveFormsModule, SentenceCasePipe, DatePipe, - DefaultDatePipe + DefaultDatePipe, + NgxDatatableModule ], templateUrl: './edit-chapter-progress.component.html', styleUrl: './edit-chapter-progress.component.scss', @@ -82,4 +84,5 @@ export class EditChapterProgressComponent implements OnInit { this.cdRef.markForCheck(); } + protected readonly ColumnMode = ColumnMode; } diff --git a/UI/Web/src/app/cards/series-card/series-card.component.html b/UI/Web/src/app/cards/series-card/series-card.component.html index 964893420..21b071da5 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.html +++ b/UI/Web/src/app/cards/series-card/series-card.component.html @@ -44,16 +44,19 @@ }
-
-
+ @if (!bulkSelectionService.hasSelections()) { +
+
+
-
+ } +
diff --git a/UI/Web/src/app/cards/volume-card/volume-card.component.html b/UI/Web/src/app/cards/volume-card/volume-card.component.html index 9484264d4..74ba7db98 100644 --- a/UI/Web/src/app/cards/volume-card/volume-card.component.html +++ b/UI/Web/src/app/cards/volume-card/volume-card.component.html @@ -38,16 +38,18 @@ }
-
-
- - -
- -
-
+ @if (!bulkSelectionService.hasSelections()) { +
+
+ + +
+ +
+
+
-
+ }
@if (libraryType === LibraryType.LightNovel || libraryType === LibraryType.Book) { diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html index f5e2053b8..4782529b3 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html @@ -102,10 +102,10 @@ - + - @if (accountService.hasValidLicense$ | async) { + @if (licenseService.hasValidLicense$ | async) {
diff --git a/UI/Web/src/app/person-detail/person-detail.component.ts b/UI/Web/src/app/person-detail/person-detail.component.ts index e8294aaa4..e86f6713b 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.ts +++ b/UI/Web/src/app/person-detail/person-detail.component.ts @@ -40,6 +40,7 @@ import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component import {ThemeService} from "../_services/theme.service"; import {DefaultModalOptions} from "../_models/default-modal-options"; import {ToastrService} from "ngx-toastr"; +import {LicenseService} from "../_services/license.service"; @Component({ selector: 'app-person-detail', @@ -74,6 +75,7 @@ export class PersonDetailComponent { private readonly modalService = inject(NgbModal); protected readonly imageService = inject(ImageService); protected readonly accountService = inject(AccountService); + protected readonly licenseService = inject(LicenseService); private readonly themeService = inject(ThemeService); private readonly toastr = inject(ToastrService); diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 3895f84f3..255c4ccda 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -87,7 +87,7 @@
} - @if ((accountService.hasValidLicense$ | async) && libraryAllowsScrobbling) { + @if ((licenseService.hasValidLicense$ | async) && libraryAllowsScrobbling) {
diff --git a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.ts b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.ts index 36563341d..d49e8742c 100644 --- a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.ts +++ b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.ts @@ -4,7 +4,7 @@ import { Component, ContentChild, ElementRef, EventEmitter, HostListener, inject, - Input, Output, + Input, OnChanges, Output, SimpleChange, SimpleChanges, TemplateRef } from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; @@ -25,7 +25,7 @@ import {AbstractControl, FormControl} from "@angular/forms"; styleUrl: './setting-item.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class SettingItemComponent { +export class SettingItemComponent implements OnChanges { private readonly cdRef = inject(ChangeDetectorRef); @@ -85,6 +85,23 @@ export class SettingItemComponent { .subscribe(); } + ngOnChanges(changes: SimpleChanges) { + if (changes.hasOwnProperty('isEditMode')) { + const change = changes.isEditMode as SimpleChange; + if (change.isFirstChange()) return; + + if (!this.toggleOnViewClick) return; + if (!this.canEdit) return; + if (this.control != null && this.control.invalid) return; + + console.log('isEditMode', this.isEditMode, 'currentValue', change.currentValue); + this.isEditMode = change.currentValue; + //this.editMode.emit(this.isEditMode); + this.cdRef.markForCheck(); + + } + } + toggleEditMode() { if (!this.toggleOnViewClick) return; diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.html b/UI/Web/src/app/settings/_components/settings/settings.component.html index 83dc23817..f16d98b04 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -6,7 +6,7 @@
- + @if (accountService.currentUser$ | async; as user) { @if (accountService.hasAdminRole(user)) { @defer (when fragment === SettingsTabId.General; prefetch on idle) { @@ -16,7 +16,7 @@
} } - + @defer (when fragment === SettingsTabId.Email; prefetch on idle) { @if (fragment === SettingsTabId.Email) {
@@ -24,7 +24,7 @@
} } - + @defer (when fragment === SettingsTabId.Media; prefetch on idle) { @if (fragment === SettingsTabId.Media) {
@@ -32,7 +32,7 @@
} } - + @defer (when fragment === SettingsTabId.Users; prefetch on idle) { @if (fragment === SettingsTabId.Users) {
@@ -40,7 +40,7 @@
} } - + @defer (when fragment === SettingsTabId.Libraries; prefetch on idle) { @if (fragment === SettingsTabId.Libraries) {
@@ -48,7 +48,7 @@
} } - + @defer (when fragment === SettingsTabId.MediaIssues; prefetch on idle) { @if (fragment === SettingsTabId.MediaIssues) {
@@ -56,7 +56,15 @@
} } - + + @defer (when fragment === SettingsTabId.EmailHistory; prefetch on idle) { + @if (fragment === SettingsTabId.EmailHistory) { +
+ +
+ } + } + @defer (when fragment === SettingsTabId.System; prefetch on idle) { @if (fragment === SettingsTabId.System) {
@@ -64,7 +72,7 @@
} } - + @defer (when fragment === SettingsTabId.Statistics; prefetch on idle) { @if (fragment === SettingsTabId.Statistics) {
@@ -72,7 +80,7 @@
} } - + @defer (when fragment === SettingsTabId.Tasks; prefetch on idle) { @if (fragment === SettingsTabId.Tasks) {
@@ -80,17 +88,49 @@
} } - - @defer (when fragment === SettingsTabId.KavitaPlus; prefetch on idle) { - @if (fragment === SettingsTabId.KavitaPlus) { + + @defer (when fragment === SettingsTabId.KavitaPlusLicense; prefetch on idle) { + @if (fragment === SettingsTabId.KavitaPlusLicense) {
- + +
+ } + } + + @defer (when fragment === SettingsTabId.MatchedMetadata; prefetch on idle) { + @if (fragment === SettingsTabId.MatchedMetadata) { +
+ +
+ } + } + + @defer (when fragment === SettingsTabId.ManageUserTokens; prefetch on idle) { + @if (fragment === SettingsTabId.ManageUserTokens) { +
+
} } } - - + + @defer (when fragment === SettingsTabId.Scrobbling; prefetch on idle) { + @if(hasActiveLicense && fragment === SettingsTabId.Scrobbling) { +
+ +
+ } + } + + @defer (when fragment === SettingsTabId.ScrobblingHolds; prefetch on idle) { + @if(hasActiveLicense && fragment === SettingsTabId.ScrobblingHolds) { +
+ +
+ } + } + + @defer (when fragment === SettingsTabId.Account; prefetch on idle) { @if (fragment === SettingsTabId.Account) {
@@ -104,7 +144,7 @@
} } - + @defer (when fragment === SettingsTabId.Preferences; prefetch on idle) { @if (fragment === SettingsTabId.Preferences) {
@@ -112,7 +152,7 @@
} } - + @defer (when fragment === SettingsTabId.Customize; prefetch on idle) { @if (fragment === SettingsTabId.Customize) {
@@ -120,7 +160,7 @@
} } - + @defer (when fragment === SettingsTabId.Clients; prefetch on idle) { @if (fragment === SettingsTabId.Clients) {
@@ -128,7 +168,7 @@
} } - + @defer (when fragment === SettingsTabId.Theme; prefetch on idle) { @if (fragment === SettingsTabId.Theme) {
@@ -136,7 +176,7 @@
} } - + @defer (when fragment === SettingsTabId.Devices; prefetch on idle) { @if (fragment === SettingsTabId.Devices) {
@@ -144,7 +184,7 @@
} } - + @defer (when fragment === SettingsTabId.UserStats; prefetch on idle) { @if (fragment === SettingsTabId.UserStats) {
@@ -152,7 +192,7 @@
} } - + @defer (when fragment === SettingsTabId.CBLImport; prefetch on idle) { @if (fragment === SettingsTabId.CBLImport) {
@@ -160,15 +200,8 @@
} } - - @defer (when fragment === SettingsTabId.Scrobbling; prefetch on idle) { - @if(hasActiveLicense && fragment === SettingsTabId.Scrobbling) { -
- -
- } - } - + + @defer (when fragment === SettingsTabId.MALStackImport; prefetch on idle) { @if(hasActiveLicense && fragment === SettingsTabId.MALStackImport) {
diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.ts b/UI/Web/src/app/settings/_components/settings/settings.component.ts index 20866b68e..b7df9099b 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -12,17 +12,12 @@ import { import { ManageUserPreferencesComponent } from "../../../user-settings/manga-user-preferences/manage-user-preferences.component"; -import {NgbNav, NgbNavContent, NgbNavLinkBase} from "@ng-bootstrap/ng-bootstrap"; -import {ActivatedRoute, Router, RouterLink} from "@angular/router"; +import {ActivatedRoute, Router} from "@angular/router"; import { SideNavCompanionBarComponent } from "../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; import {ThemeManagerComponent} from "../../../user-settings/theme-manager/theme-manager.component"; import {TranslocoDirective} from "@jsverse/transloco"; -import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobbling-holds.component"; -import { - UserScrobbleHistoryComponent -} from "../../../_single-module/user-scrobble-history/user-scrobble-history.component"; import {UserStatsComponent} from "../../../statistics/_components/user-stats/user-stats.component"; import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.component"; import {AsyncPipe} from "@angular/common"; @@ -40,10 +35,6 @@ import {ServerStatsComponent} from "../../../statistics/_components/server-stats import {SettingFragmentPipe} from "../../../_pipes/setting-fragment.pipe"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {tap} from "rxjs"; -import { - KavitaplusMetadataBreakdownStatsComponent -} from "../../../statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component"; -import {ManageKavitaplusComponent} from "../../../admin/manage-kavitaplus/manage-kavitaplus.component"; import {ManageScrobblingComponent} from "../../../admin/manage-scrobling/manage-scrobbling.component"; import {ManageMediaIssuesComponent} from "../../../admin/manage-media-issues/manage-media-issues.component"; import { @@ -53,6 +44,11 @@ import { ImportMalCollectionComponent } from "../../../collections/_components/import-mal-collection/import-mal-collection.component"; import {ImportCblComponent} from "../../../reading-list/_components/import-cbl/import-cbl.component"; +import {LicenseService} from "../../../_services/license.service"; +import {ManageMatchedMetadataComponent} from "../../../admin/manage-matched-metadata/manage-matched-metadata.component"; +import {ManageUserTokensComponent} from "../../../admin/manage-user-tokens/manage-user-tokens.component"; +import {EmailHistoryComponent} from "../../../admin/email-history/email-history.component"; +import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobbling-holds.component"; @Component({ selector: 'app-settings', @@ -65,15 +61,9 @@ import {ImportCblComponent} from "../../../reading-list/_components/import-cbl/i ManageOpdsComponent, ManageScrobblingProvidersComponent, ManageUserPreferencesComponent, - NgbNav, - NgbNavContent, - NgbNavLinkBase, - RouterLink, SideNavCompanionBarComponent, ThemeManagerComponent, TranslocoDirective, - ScrobblingHoldsComponent, - UserScrobbleHistoryComponent, UserStatsComponent, AsyncPipe, LicenseComponent, @@ -86,13 +76,15 @@ import {ImportCblComponent} from "../../../reading-list/_components/import-cbl/i ManageUsersComponent, ServerStatsComponent, SettingFragmentPipe, - KavitaplusMetadataBreakdownStatsComponent, - ManageKavitaplusComponent, ManageScrobblingComponent, ManageMediaIssuesComponent, ManageCustomizationComponent, ImportMalCollectionComponent, - ImportCblComponent + ImportCblComponent, + ManageMatchedMetadataComponent, + ManageUserTokensComponent, + EmailHistoryComponent, + ScrobblingHoldsComponent ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', @@ -105,6 +97,7 @@ export class SettingsComponent { private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); protected readonly accountService = inject(AccountService); + protected readonly licenseService = inject(LicenseService); protected readonly SettingsTabId = SettingsTabId; protected readonly WikiLink = WikiLink; @@ -128,7 +121,7 @@ export class SettingsComponent { this.cdRef.markForCheck(); }), takeUntilDestroyed(this.destroyRef)).subscribe(); - this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { + this.licenseService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { if (res) { this.hasActiveLicense = true; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts index df4ccc4a9..481c9b48c 100644 --- a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts +++ b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts @@ -1,7 +1,7 @@ import { ConfirmButton } from './confirm-button'; export class ConfirmConfig { - _type: 'confirm' | 'alert' = 'confirm'; + _type: 'confirm' | 'alert' | 'info' = 'confirm'; header: string = 'Confirm'; content: string = ''; buttons: Array = []; diff --git a/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html b/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html index 524189c09..21b741cd3 100644 --- a/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html +++ b/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html @@ -1,14 +1,18 @@ -
- - - - - - -
{{day.name}} {{day.value}}
-
-
+ @if (dayBreakdown$ | async; as days) { + @if (days.length === 0) { +
{{t('no-data')}}
+ } @else { +
+ + + @for(day of days; track day) { + + + + + } + +
{{day.name}} {{day.value}}
+
+ } + }
diff --git a/UI/Web/src/app/statistics/_components/day-breakdown/day-breakdown.component.ts b/UI/Web/src/app/statistics/_components/day-breakdown/day-breakdown.component.ts index 1d57f89ef..acf16b87a 100644 --- a/UI/Web/src/app/statistics/_components/day-breakdown/day-breakdown.component.ts +++ b/UI/Web/src/app/statistics/_components/day-breakdown/day-breakdown.component.ts @@ -7,7 +7,7 @@ import {PieDataItem} from '../../_models/pie-data-item'; import {StatCount} from '../../_models/stat-count'; import {DayOfWeekPipe} from '../../../_pipes/day-of-week.pipe'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {AsyncPipe, NgForOf, NgIf} from '@angular/common'; +import {AsyncPipe} from '@angular/common'; import {TranslocoDirective} from "@jsverse/transloco"; import {tap} from "rxjs/operators"; @@ -17,7 +17,7 @@ import {tap} from "rxjs/operators"; styleUrls: ['./day-breakdown.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [BarChartModule, AsyncPipe, TranslocoDirective, NgForOf, NgIf] + imports: [BarChartModule, AsyncPipe, TranslocoDirective] }) export class DayBreakdownComponent implements OnInit { diff --git a/UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.html b/UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.html index 3379b6689..0f6889382 100644 --- a/UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.html +++ b/UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.html @@ -83,9 +83,4 @@
- - - - -
diff --git a/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.html b/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.html deleted file mode 100644 index 202fbbce6..000000000 --- a/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.html +++ /dev/null @@ -1,24 +0,0 @@ - -
-

{{t('title')}}

-

{{t('description')}}

- - @if(breakdown) { - @if(breakdown.totalSeries === 0 || breakdown.seriesCompleted === 0) { -
{{t('no-data')}}
- } @else { - - - - - @if (breakdown.seriesCompleted >= breakdown.totalSeries) { -

{{t('complete') }} - @if (breakdown.erroredSeries > 0) { - {{t('errored-series-label') }} {{breakdown.erroredSeries}} - } -

- } - } - } -
-
diff --git a/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.scss b/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.scss deleted file mode 100644 index b5451441a..000000000 --- a/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.scss +++ /dev/null @@ -1,22 +0,0 @@ -.dashboard-card-content { - max-width: 400px; - height: auto; - box-sizing:border-box; -} - -.day-breakdown-chart { - width: 100%; - margin: 0 auto; - max-width: 400px; -} - -.error { - color: var(--error-color); -} -.completed { - color: var(--color-5); -} - -.progress-bar-danger.progress-bar { - background-color: var(--error-color); -} diff --git a/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.ts b/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.ts deleted file mode 100644 index 3061497d6..000000000 --- a/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; -import {StatisticsService} from "../../../_services/statistics.service"; -import {KavitaPlusMetadataBreakdown} from "../../_models/kavitaplus-metadata-breakdown"; -import {TranslocoDirective} from "@jsverse/transloco"; -import {PercentPipe} from "@angular/common"; -import {NgbProgressbar, NgbProgressbarStacked, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; - -@Component({ - selector: 'app-kavitaplus-metadata-breakdown-stats', - standalone: true, - imports: [ - TranslocoDirective, - PercentPipe, - NgbProgressbarStacked, - NgbProgressbar, - NgbTooltip - ], - templateUrl: './kavitaplus-metadata-breakdown-stats.component.html', - styleUrl: './kavitaplus-metadata-breakdown-stats.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class KavitaplusMetadataBreakdownStatsComponent { - private readonly cdRef = inject(ChangeDetectorRef); - private readonly statsService = inject(StatisticsService); - - breakdown: KavitaPlusMetadataBreakdown | undefined; - - errorPercent!: number; - completedPercent!: number; - - - constructor() { - this.statsService.getKavitaPlusMetadataBreakdown().subscribe(res => { - this.breakdown = res; - - this.errorPercent = (res.erroredSeries / res.totalSeries) * 100; - this.completedPercent = ((res.seriesCompleted - res.erroredSeries) / res.totalSeries) * 100; - - this.cdRef.markForCheck(); - }); - } -} diff --git a/UI/Web/src/app/statistics/_components/publication-status-stats/publication-status-stats.component.html b/UI/Web/src/app/statistics/_components/publication-status-stats/publication-status-stats.component.html index da0b35b8d..d04a544d9 100644 --- a/UI/Web/src/app/statistics/_components/publication-status-stats/publication-status-stats.component.html +++ b/UI/Web/src/app/statistics/_components/publication-status-stats/publication-status-stats.component.html @@ -17,6 +17,7 @@ + {{t('description')}}

-
+ -
- - - - - - - - - - - - @for(device of devices; track device.name + device.emailAddress + device.platform) { - - - - - - - } @empty { - - } - -
+ + {{t('name-label')}} - + + + {{ item.name }} + + + + + + {{t('email-label')}} - + + + {{ item.emailAddress }} + + + + + {{t('platform-label')}} - - {{t('actions-header')}} -
{{ device.name }}{{ device.emailAddress }}{{ device.platform | devicePlatform }} - + + + {{ item.platform | devicePlatform }} + + - -
{{t('no-data')}}
+ + + + + + + + +
diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts index 5659b26c2..86173f8d5 100644 --- a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts @@ -24,6 +24,8 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {map} from "rxjs"; import {shareReplay} from "rxjs/operators"; import {AccountService} from "../../_services/account.service"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {AsyncPipe, TitleCasePipe} from "@angular/common"; @Component({ selector: 'app-manage-devices', @@ -31,8 +33,8 @@ import {AccountService} from "../../_services/account.service"; styleUrls: ['./manage-devices.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgbCollapse, SentenceCasePipe, DevicePlatformPipe, TranslocoDirective, SettingItemComponent, - DefaultValuePipe, ScrobbleEventTypePipe, SortableHeader, UtcToLocalTimePipe] + imports: [NgbCollapse, SentenceCasePipe, DevicePlatformPipe, TranslocoDirective, SettingItemComponent, + DefaultValuePipe, ScrobbleEventTypePipe, SortableHeader, UtcToLocalTimePipe, AsyncPipe, NgxDatatableModule, TitleCasePipe] }) export class ManageDevicesComponent implements OnInit { @@ -106,4 +108,5 @@ export class ManageDevicesComponent implements OnInit { }); } + protected readonly ColumnMode = ColumnMode; } diff --git a/UI/Web/src/app/user-settings/manage-opds/manage-opds.component.ts b/UI/Web/src/app/user-settings/manage-opds/manage-opds.component.ts index 4349e9d5d..f3db26a69 100644 --- a/UI/Web/src/app/user-settings/manage-opds/manage-opds.component.ts +++ b/UI/Web/src/app/user-settings/manage-opds/manage-opds.component.ts @@ -8,6 +8,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {WikiLink} from "../../_models/wiki"; +import {LicenseService} from "../../_services/license.service"; @Component({ selector: 'app-manage-opds', @@ -27,6 +28,7 @@ export class ManageOpdsComponent { private readonly accountService = inject(AccountService); private readonly settingsService = inject(SettingsService); private readonly cdRef = inject(ChangeDetectorRef); + private readonly licenseService = inject(LicenseService); user: User | undefined = undefined; @@ -48,7 +50,7 @@ export class ManageOpdsComponent { this.cdRef.markForCheck(); }); - this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { + this.licenseService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { this.hasActiveLicense = res; this.cdRef.markForCheck(); }); diff --git a/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.html b/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.html index e0cf43c74..3425d4b6c 100644 --- a/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.html +++ b/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.html @@ -12,6 +12,7 @@

{{t('instructions', {service: ScrobbleProvider.AniList | scrobbleProviderName})}}

+

{{t('anilist-used-for')}}

@@ -32,6 +33,7 @@

{{t('mal-instructions', {service: ScrobbleProvider.Mal | scrobbleProviderName})}}

+

{{t('mal-used-for')}}

diff --git a/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.ts b/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.ts index d62d0d083..d56908d81 100644 --- a/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.ts +++ b/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.ts @@ -1,50 +1,29 @@ import {ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; -import {NgForOf, NgIf, NgOptimizedImage} from "@angular/common"; -import { - NgbAccordionBody, - NgbAccordionButton, - NgbAccordionCollapse, NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem, - NgbCollapse, - NgbTooltip -} from "@ng-bootstrap/ng-bootstrap"; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {Select2Module} from "ng-select2-component"; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {AccountService} from "../../_services/account.service"; import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service"; import {ToastrService} from "ngx-toastr"; -import {ManageMediaIssuesComponent} from "../../admin/manage-media-issues/manage-media-issues.component"; import {LoadingComponent} from "../../shared/loading/loading.component"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ScrobbleProviderItemComponent} from "../scrobble-provider-item/scrobble-provider-item.component"; import {ScrobbleProviderNamePipe} from "../../_pipes/scrobble-provider-name.pipe"; import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component"; -import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {LicenseService} from "../../_services/license.service"; +import {ConfirmService} from "../../shared/confirm.service"; @Component({ selector: 'app-manage-scrobbling-providers', standalone: true, imports: [ - NgOptimizedImage, - NgbTooltip, ReactiveFormsModule, Select2Module, TranslocoDirective, - NgbCollapse, - ManageMediaIssuesComponent, - NgbAccordionBody, - NgbAccordionButton, - NgbAccordionCollapse, - NgbAccordionDirective, - NgbAccordionHeader, - NgbAccordionItem, LoadingComponent, ScrobbleProviderItemComponent, ScrobbleProviderNamePipe, SettingTitleComponent, - NgForOf, - NgIf, - SettingItemComponent, ], templateUrl: './manage-scrobbling-providers.component.html', styleUrl: './manage-scrobbling-providers.component.scss' @@ -55,6 +34,8 @@ export class ManageScrobblingProvidersComponent implements OnInit { private readonly toastr = inject(ToastrService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); + private readonly licenseService = inject(LicenseService); + private readonly confirmService = inject(ConfirmService); protected readonly ScrobbleProvider = ScrobbleProvider; @@ -70,7 +51,7 @@ export class ManageScrobblingProvidersComponent implements OnInit { loaded: boolean = false; constructor() { - this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { + this.licenseService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { this.hasValidLicense = res; this.cdRef.markForCheck(); if (this.hasValidLicense) { @@ -110,7 +91,30 @@ export class ManageScrobblingProvidersComponent implements OnInit { } saveAniListForm() { - this.scrobblingService.updateAniListToken(this.formGroup.get('aniListToken')!.value).subscribe(() => { + this.scrobblingService.updateAniListToken(this.formGroup.get('aniListToken')!.value).subscribe(async (isFirstToken) => { + + if (isFirstToken) { + const result = await this.confirmService.confirm('', { + buttons: [ + {text: translate('scrobbling-providers.anilist-first-later'), type: 'secondary'}, + {text: translate('scrobbling-providers.anilist-first-now'), type: 'primary'}, + ], + _type: 'confirm', + content: translate('scrobbling-providers.anilist-first-description'), + header: translate('scrobbling-providers.anilist-first-header'), + disableEscape: true + }); + // false is Later, true is Now + if (result) { + this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => { + this.aniListToken = this.formGroup.get('aniListToken')!.value; + this.resetForm(); + this.cdRef.markForCheck(); + }); + return; + } + } + this.toastr.success(translate('toasts.anilist-token-updated')); this.aniListToken = this.formGroup.get('aniListToken')!.value; this.resetForm(); diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts index 8763220f9..546fdde45 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts @@ -129,6 +129,7 @@ export class ManageUserPreferencesComponent implements OnInit { get Locale() { if (!this.settingsForm.get('locale')) return 'English'; + console.log(this.locales.filter(l => l.isoCode === this.settingsForm.get('locale')!.value)[0]) return this.locales.filter(l => l.isoCode === this.settingsForm.get('locale')!.value)[0].title; } diff --git a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html index 00f469714..4912e7442 100644 --- a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html +++ b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html @@ -1,20 +1,43 @@ -
{{t('title')}}

{{t('description')}}

-
-
    - @for(hold of holds$ | async; track hold.seriesName) { -
  • - {{hold.seriesName}} -
  • - } @empty { -
  • - {{t('no-data')}} -
  • - } + -
-
+ + + {{t('series-name-header')}} + + + + {{item.seriesName}} + + + + + + {{t('created-header')}} + + + {{item.createdUtc | utcToLocalTime}} + + + + + + + + + + + +
diff --git a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.ts b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.ts index b5b3c747a..884a2ee2a 100644 --- a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.ts +++ b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.ts @@ -1,34 +1,45 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject} from '@angular/core'; -import {AsyncPipe} from '@angular/common'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; import {ScrobblingService} from "../../_services/scrobbling.service"; -import {shareReplay} from "rxjs/operators"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {ScrobbleEventTypePipe} from "../../_pipes/scrobble-event-type.pipe"; - -import { - NgbAccordionBody, - NgbAccordionCollapse, - NgbAccordionDirective, - NgbAccordionHeader, - NgbAccordionItem -} from "@ng-bootstrap/ng-bootstrap"; import {TranslocoDirective} from "@jsverse/transloco"; import {ImageService} from "../../_services/image.service"; import {ImageComponent} from "../../shared/image/image.component"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {ScrobbleHold} from "../../_models/scrobbling/scrobble-hold"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; @Component({ selector: 'app-user-holds', standalone: true, - imports: [ScrobbleEventTypePipe, NgbAccordionDirective, NgbAccordionCollapse, NgbAccordionBody, - NgbAccordionItem, NgbAccordionHeader, TranslocoDirective, AsyncPipe, ImageComponent], + imports: [TranslocoDirective, ImageComponent, UtcToLocalTimePipe, NgxDatatableModule], templateUrl: './scrobbling-holds.component.html', styleUrls: ['./scrobbling-holds.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class ScrobblingHoldsComponent { + protected readonly ColumnMode = ColumnMode; + private readonly cdRef = inject(ChangeDetectorRef); private readonly scrobblingService = inject(ScrobblingService); - private readonly destroyRef = inject(DestroyRef); protected readonly imageService = inject(ImageService); - holds$ = this.scrobblingService.getHolds().pipe(takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); + + isLoading = true; + data: Array = []; + + constructor() { + this.loadData(); + } + + loadData() { + this.scrobblingService.getHolds().subscribe(data => { + this.data = data; + this.isLoading = false; + this.cdRef.markForCheck(); + }) + } + + removeHold(hold: ScrobbleHold) { + this.scrobblingService.removeHold(hold.seriesId).subscribe(_ => { + this.loadData(); + }); + } } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 6b4e9fd2b..c264153bf 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -42,7 +42,7 @@ "series-header": "Series", "data-header": "Data", "is-processed-header": "Is Processed", - "no-data": "No Data", + "no-data": "{{common.no-data}}", "volume-and-chapter-num": "Volume {{v}} Chapter {{n}}", "volume-num": "Volume {{num}}", "chapter-num": "Chapter {{num}}", @@ -50,7 +50,9 @@ "not-applicable": "Not Applicable", "processed": "Processed", "not-processed": "Not Processed", - "special": "{{entity-title.special}}" + "special": "{{entity-title.special}}", + "generate-scrobble-events": "Backfill Events", + "token-expired": "Your AniList token is Expired! Scrobbling events will not process until you renew on Accounts page." }, "scrobble-event-type-pipe": { @@ -191,8 +193,12 @@ "user-holds": { "title": "Scrobble Holds", - "description": "This is a user-managed list of Series that will not be scrobbled to upstream providers. You can remove a series at any time and the next scrobble-able event (reading progress, rating, want to read status) will trigger events.", - "no-data": "{{typeahead.no-data}}" + "description": "This is a user-managed list of Series that will not be scrobbled to upstream providers. You can remove a series at any time and the next scrobble-able event (reading progress, rating, want to read status) will trigger scrobbling.", + "no-data": "{{typeahead.no-data}}", + "series-name-header": "{{manage-matched-metadata.series-name-header}}", + "created-header": "{{manage-media-issues.created-header}}", + "delete-label": "{{common.remove}}" + }, "theme-manager": { @@ -345,7 +351,14 @@ "edit": "{{common.edit}}", "cancel": "{{common.cancel}}", "save": "{{common.save}}", - "loading": "{{common.loading}}" + "loading": "{{common.loading}}", + "anilist-used-for": "AniList is used for scrobbling (syncing data) and want to read syncing.", + "mal-used-for": "MyAnimeList is used for Smart Collections and want to read syncing.", + "anilist-first-header": "AniList token saved", + "anilist-first-description": "Your AniList token is now setup. This token lasts for a year and Kavita will inform you before it expires to refresh it here. With this, Kavita will generate scrobble (sync) events from your reading history, want to read, and ratings. You can start this now or later. If you have any series that you do not want to be scrobbled select later and place a Scrobble Hold on them. You only need to run this task once. Afterwards, Kavita will handle the syncing for you.
If you select 'Later', you can find the button on Scrobbling page.", + "anilist-first-later": "Later", + "anilist-first-now": "Now" + }, "typeahead": { @@ -353,7 +366,7 @@ "close": "{{common.close}}", "loading": "{{common.loading}}", "add-item": "Add {{item}}…", - "no-data": "No data", + "no-data": "{{common.no-data}}", "add-custom-item": ", type to add a custom item" }, @@ -623,10 +636,34 @@ "installed": "Installed", "download": "Download", "nightly": "Nightly: {{version}}", + "available": "Available" + }, + + "changelog-update-item": { + "download": "Download", + "added": "Added", + "changed": "Changed", + "fixed": "Fixed", + "developer": "Developer", + "theme": "Theme", + "removed": "Removed", + "api": "API", "published-label": "Published: ", - "available": "Available", - "description": "If you do not see an {{installed}}", - "description-continued": "tag, you are on a nightly release. Only major versions will show as available. A nightly tag will show when on a nightly from that stable." + "installed": "{{changelog.installed}}" + }, + + "new-version-modal": { + "title": "Kavita was updated!", + "description": "A new version of Kavita is available. Refresh to update.", + "added": "{{changelog.added}}", + "changed": "{{changelog.changed}}", + "fixed": "{{changelog.fixed}}", + "developer": "{{changelog.developer}}", + "theme": "{{changelog.theme}}", + "removed": "{{changelog.removed}}", + "api": "{{changelog.api}}", + "close": "{{common.close}}", + "refresh": "Refresh" }, "invite-user": { @@ -669,6 +706,29 @@ "license-not-valid": "License Not Valid", "loading": "{{common.loading}}", "help-label": "{{common.help}}", + "info-title": "License Info", + "actions-title": "Actions", + "license-active-label": "License Active", + "supported-version-label": "On Supported Kavita version", + "expiration-label": "Expiration", + "valid-tooltip": "Valid", + "invalid-tooltip": "Invalid", + "reset-label": "Reset", + "reset-tooltip": "This will reset the server instance Kavita+ license is tied to. Requires Email and License Key", + "delete-label": "Remove License", + "delete-tooltip": "This will remove the license from Kavita to stop Kavita from talking to Kavita+. Does not unsubscribe you. Use 'manage' first to cancel the subscription.", + "manage-tooltip": "Update payment info, Cancel/Pause subscription, etc", + "faq-title": "FAQ", + "total-subbed-months-label": "Total Months Subscribed", + "email-label": "Registered Email", + + "license-mismatch": "License may be registered to another Kavita instance. Re-register to fix.", + "k+-license-overwrite": "License is registered to another Kavita instance. This can happen on re-installation and rarely due to system updates. Select overwrite to force this instance to register with Kavita+.", + "k+-already-registered-header": "License already registered", + "overwrite": "Overwrite", + + "k+-unlocked": "Kavita+ unlocked!", + "k+-unlocked-description": "Welcome to Kavita+! Kavita will generate scrobble events from your progress, ratings and want to read once you register your Scrobbling Providers under account. Take some time to ensure you place scrobbling holds for any series you don't want to scrobble.", "activate-description": "Enter the License Key and Email used to register with Stripe", "activate-license-label": "License Key", @@ -677,15 +737,38 @@ "activate-discordId-tooltip": "Link your Discord Account with Kavita+. This grants you access to hidden channels to help shape Kavita.", "discord-validation": "This is not a valid Discord User Id. Your user id is not your discord username.", "activate-delete": "{{common.delete}}", - "activate-reset": "{{common.reset}}", + "activate-reset": "Reset License", "activate-reset-tooltip": "Invalidate a previous registration using your license. Requires both License and Email", "activate-save": "{{common.save}}", "kavita+-desc-part-1": "Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock ", "kavita+-desc-part-2": "premium benefits", "kavita+-desc-part-3": "today!", - "kavita+-requirement": "Kavita+ is designed to work only with the latest release - 2 versions. Anything outside of that is subject to not working.", - "kavita+-releases": "See releases" + "kavita+-requirement": "Kavita+ is designed to work only with the latest 3 releases. Anything outside of that is subject to not functioning." + }, + + "manage-matched-metadata": { + "title": "Matched Metadata", + "description": "All applicable Series that can be matched with External Metadata reside here. Kavita will prefetch or refresh series metadata, 50 series per 24 hours daily.", + "status-header": "Status", + "series-name-header": "Series", + "valid-until-header": "Next Refresh", + "matched-status-label": "Matched", + "unmatched-status-label": "Not Matched", + "blacklist-status-label": "Needs Manual Match", + "all-status-label": "All", + "dont-match-label": "Don't Match", + "no-data": "{{common.no-data}}" + }, + + "manage-user-tokens": { + "description": "Users that have scrobbling tokens may need renewing occasionally. Kavita will automatically email them if email is setup and they have a valid email.", + "username-header": "Username", + "anilist-header": "AniList", + "mal-header": "MAL", + "token-set-label": "Token Set", + "no-data": "{{common.no-data}}", + "expires-label": "Expires: {{date}}" }, "book-line-overlay": { @@ -838,6 +921,26 @@ "publication-status-tooltip": "Publication Status" }, + "match-series-modal": { + "title": "Match {{seriesName}}", + "description": "Select a match to rewire Kavita+ metadata and regenerate scrobble events. Don't Match can be used to restrict Kavita from matching metadata and scrobbling.", + "close": "{{common.close}}", + "save": "{{common.save}}", + "no-results": "Unable to find a match. Try adding the url from a supported provider and retry.", + "query-label": "Query", + "query-tooltip": "Enter series name, AniList/MyAnimeList url. Urls will use a direct lookup.", + "dont-match-label": "Do not Match", + "dont-match-tooltip": "Opt this series from matching and scrobbling", + "search": "Search" + }, + + "match-series-result-item": { + "volume-count": "{{server-stats.volume-count}}", + "chapter-count": "{{common.chapter-count}}", + "releasing": "Releasing", + "details": "View page" + }, + "metadata-fields": { "collections-title": "{{side-nav.collections}}", "reading-lists-title": "{{side-nav.reading-lists}}", @@ -1141,6 +1244,17 @@ "open-external": "Open External" }, + "email-history": { + "description": "Here you can find all emails sent from Kavita and to which user.", + "template-header": "Template", + "date-header": "Send Date", + "user-header": "Sent To", + "sent-header": "Successful", + "no-data": "{{common.no-data}}", + "sent-tooltip": "Sent", + "not-sent-tooltip": "Not Sent" + }, + "manage-media-issues": { "description-part-1": "This table contains issues found during scan or reading of your media. You can clear it at any time and use Library (Force) Scan to perform analysis. A list of some common errors and what they mean can be found on the ", @@ -1150,7 +1264,7 @@ "file-header": "File", "comment-header": "Comment", "created-header": "Created", - "no-data": "No Data" + "no-data": "{{common.no-data}}" }, "manage-email-settings": { @@ -1487,7 +1601,7 @@ "server-section-title": "Server", "info-section-title": "Info", "import-section-title": "Import", - "kavitaplus-section-title": "{{settings.admin-kavitaplus}}", + "kavitaplus-section-title": "Kavita+", "admin-general": "General", "admin-users": "Users", "admin-libraries": "Libraries", @@ -1498,7 +1612,11 @@ "admin-tasks": "Tasks", "admin-statistics": "Statistics", "admin-system": "System", - "admin-kavitaplus": "Kavita+", + "admin-email-history": "Email History", + "admin-kavitaplus": "License", + "admin-matched-metadata": "Matched Metadata", + "admin-manage-tokens": "Manage User Tokens", + "scrobble-holds": "Scrobble Holds", "account": "Account", "preferences": "Preferences", "clients": "API Key / OPDS", @@ -2050,7 +2168,7 @@ "kavitaplus-metadata-breakdown-stats": { "title": "Kavita+ Metadata Breakdown", "description": "Kavita fetches metadata (ratings, reviews, recommendations, etc) slowly over time for eligible series.", - "no-data": "No data", + "no-data": "{{common.no-data}}", "errored-series-label": "Errored Series", "completed-series-label": "Completed Series", "complete": "All Series have metadata" @@ -2315,7 +2433,10 @@ "confirm": { "alert": "Alert", - "confirm": "Confirm" + "confirm": "Confirm", + "info": "Info", + "cancel": "{{common.cancel}}", + "ok": "Ok" }, "toasts": { @@ -2368,9 +2489,8 @@ "email-sent": "Email sent to {{email}}", "email-not-sent": "Email on file is not a valid email and can not be sent. A link has been dumped in logs. The admin can provide this link to complete flow.", "k+-license-saved": "License Key saved, but it is not valid. Click check to revalidate the subscription. First time registration may take a min to propagate.", - "k+-unlocked": "Kavita+ unlocked!", "k+-error": "There was an error when activating your license. Please try again.", - "k+-delete-key": "This will only delete Kavita's license key and allow a buy link to show. This will not cancel your subscription! Use this only if directed by support!", + "k+-delete-key": "This will only delete Kavita's license key and allow a buy link to show. This will not cancel your subscription! Use Manage to cancel your subscription first.", "k+-reset-key": "This will invalidate a previous registration using your license and allow you to re-register a Kavita instance.", "k+-reset-key-success": "Your license has been un-registered. Use Edit button to re-register your instance and re-activate Kavita+", "library-deleted": "Library {{name}} has been removed", @@ -2434,7 +2554,8 @@ "bulk-scan": "Scanning multiple libraries will be done linearly. This may take a long time and not complete depending on library size.", "bulk-covers": "Refreshing covers on multiple libraries is intensive and can take a long time. Are you sure you want to continue?", "person-image-downloaded": "Person cover was downloaded and applied.", - "bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?" + "bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?", + "match-success": "Series matched correctly" }, "read-time-pipe": { @@ -2507,7 +2628,9 @@ "multiple-selections": "Multiple Selections", "back-to": "Back to {{action}}", "title": "Actions", - "copy-settings": "Copy Settings From" + "copy-settings": "Copy Settings From", + "match": "Match", + "match-description": "Match Series with Kavita+ manually" }, "preferences": { @@ -2618,6 +2741,8 @@ "series-count": "{{num}} Series", "author-count": "{{num}} Authors", "item-count": "{{num}} Items", + "chapter-count": "{{num}} Chapters", + "no-data": "No Data", "book-num": "Book", "issue-hash-num": "Issue #", diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index 889a89642..acdeb65f1 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -14,6 +14,9 @@ @import '../node_modules/charts.css/dist/charts.min'; +@import '../node_modules/@siemens/ngx-datatable/index.css'; +@import '../node_modules/@siemens/ngx-datatable/themes/bootstrap.css'; +@import '../node_modules/@siemens/ngx-datatable/assets/icons.css'; // Import all the customized theme overrides @@ -120,4 +123,4 @@ body { height: 1px; background-color: var(--setting-break-color); margin: 30px 0; -} \ No newline at end of file +} diff --git a/UI/Web/src/theme/components/_table.scss b/UI/Web/src/theme/components/_table.scss index 72ede9819..201f9f43d 100644 --- a/UI/Web/src/theme/components/_table.scss +++ b/UI/Web/src/theme/components/_table.scss @@ -45,5 +45,29 @@ th[sortable].desc:after { --bs-table-bg: var(--table-body-striped-bg-color); } +.ngx-datatable.bootstrap { + + thead { + color: var(--table-header-text-color); + background-color: var(--table-header-bg-color); + } + + .datatable-row-wrapper { + border-style: var(--table-body-border); + } +} + +.datatable-body-row.datatable-row-even { + border-style: var(--table-body-border); + background-color: var(--table-body-striped-bg-color); +} + +.datatable-footer .disabled { + .datatable-icon-left, .datatable-icon-right, .datatable-icon-skip, .datatable-icon-prev { + color: var(--table-header-text-color-disabled); + } +} + + diff --git a/global.json b/global.json index b5b37b60d..a27a2b823 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.0", + "version": "9.0.0", "rollForward": "latestMajor", "allowPrerelease": false } diff --git a/openapi.json b/openapi.json index 7071cdaff..99e71ec35 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.3.2", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.6", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" @@ -1194,6 +1194,67 @@ } } }, + "/api/Chapter/delete-multiple": { + "post": { + "tags": [ + "Chapter" + ], + "summary": "Deletes multiple chapters and any volumes with no leftover chapters", + "parameters": [ + { + "name": "seriesId", + "in": "query", + "description": "The ID of the series", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "The IDs of the chapters to be deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteChaptersDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DeleteChaptersDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/DeleteChaptersDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "boolean" + } + }, + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, "/api/Chapter/update": { "post": { "tags": [ @@ -2005,7 +2066,9 @@ "tags": [ "Device" ], + "summary": "Attempts to send a whole series to a device.", "requestBody": { + "description": "", "content": { "application/json": { "schema": { @@ -2266,6 +2329,44 @@ } } }, + "/api/Email/all": { + "get": { + "tags": [ + "Email" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailHistoryDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailHistoryDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailHistoryDto" + } + } + } + } + } + } + } + }, "/api/Filter/update": { "post": { "tags": [ @@ -2758,6 +2859,67 @@ } } }, + "/api/Image/person-cover": { + "get": { + "tags": [ + "Image" + ], + "summary": "Returns cover image for Person", + "parameters": [ + { + "name": "personId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "apiKey", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/Image/person-cover-by-name": { + "get": { + "tags": [ + "Image" + ], + "summary": "Returns cover image for Person", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + }, + { + "name": "apiKey", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Image/cover-upload": { "get": { "tags": [ @@ -3341,10 +3503,13 @@ "tags": [ "Library" ], + "summary": "Deletes the library and all series within it.", + "description": "This does not touch any files", "parameters": [ { "name": "libraryId", "in": "query", + "description": "", "schema": { "type": "integer", "format": "int32" @@ -3375,6 +3540,51 @@ } } }, + "/api/Library/delete-multiple": { + "delete": { + "tags": [ + "Library" + ], + "summary": "Deletes multiple libraries and all series within it.", + "description": "This does not touch any files", + "parameters": [ + { + "name": "libraryIds", + "in": "query", + "description": "", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "boolean" + } + }, + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, "/api/Library/name-exists": { "get": { "tags": [ @@ -3562,7 +3772,7 @@ "tags": [ "License" ], - "summary": "Has any license", + "summary": "Has any license registered with the instance. Does not check Kavita+ API", "responses": { "200": { "description": "OK", @@ -3587,6 +3797,47 @@ } } }, + "/api/License/info": { + "get": { + "tags": [ + "License" + ], + "summary": "Asks Kavita+ for the latest license info", + "parameters": [ + { + "name": "forceCheck", + "in": "query", + "description": "Force checking the API and skip the 8 hour cache", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/LicenseInfoDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicenseInfoDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LicenseInfoDto" + } + } + } + } + } + } + }, "/api/License": { "delete": { "tags": [ @@ -3699,6 +3950,64 @@ } } }, + "/api/Manage/series-metadata": { + "post": { + "tags": [ + "Manage" + ], + "summary": "Returns a list of all Series that is Kavita+ applicable to metadata match and the status of it", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManageMatchFilterDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ManageMatchFilterDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ManageMatchFilterDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManageMatchSeriesDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManageMatchSeriesDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManageMatchSeriesDto" + } + } + } + } + } + } + } + }, "/api/Metadata/genres": { "get": { "tags": [ @@ -3713,6 +4022,23 @@ "schema": { "type": "string" } + }, + { + "name": "context", + "in": "query", + "description": "Context from which this API was invoked", + "schema": { + "enum": [ + 1, + 2, + 3, + 4 + ], + "type": "integer", + "description": "For complex queries, Library has certain restrictions where the library should not be included in results.\r\nThis enum dictates which field to use for the lookup.", + "format": "int32", + "default": 1 + } } ], "responses": { @@ -4101,20 +4427,19 @@ } } }, - "/api/Metadata/chapter-summary": { + "/api/Metadata/language-title": { "get": { "tags": [ "Metadata" ], - "summary": "Returns summary for the chapter", + "summary": "Given a language code returns the display name", "parameters": [ { - "name": "chapterId", + "name": "code", "in": "query", "description": "", "schema": { - "type": "integer", - "format": "int32" + "type": "string" } } ], @@ -4142,30 +4467,6 @@ } } }, - "/api/Metadata/force-refresh": { - "post": { - "tags": [ - "Metadata" - ], - "summary": "If this Series is on Kavita+ Blacklist, removes it. If already cached, invalidates it.\r\nThis then attempts to refresh data from Kavita+ for this series.", - "parameters": [ - { - "name": "seriesId", - "in": "query", - "description": "", - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, "/api/Metadata/series-detail-plus": { "get": { "tags": [ @@ -4715,10 +5016,12 @@ "tags": [ "Opds" ], + "summary": "OPDS Search endpoint", "parameters": [ { "name": "apiKey", "in": "path", + "description": "", "required": true, "schema": { "type": "string" @@ -4727,6 +5030,7 @@ { "name": "query", "in": "query", + "description": "", "schema": { "type": "string" } @@ -5136,6 +5440,418 @@ } } }, + "/api/Person": { + "get": { + "tags": [ + "Person" + ], + "parameters": [ + { + "name": "name", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + } + } + } + } + } + }, + "/api/Person/roles": { + "get": { + "tags": [ + "Person" + ], + "summary": "Returns all roles for a Person", + "parameters": [ + { + "name": "personId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "enum": [ + 1, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "type": "integer", + "format": "int32" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "enum": [ + 1, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "type": "integer", + "format": "int32" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "enum": [ + 1, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "type": "integer", + "format": "int32" + } + } + } + } + } + } + } + }, + "/api/Person/all": { + "post": { + "tags": [ + "Person" + ], + "parameters": [ + { + "name": "PageNumber", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "description": "If set to 0, will set as MaxInt", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BrowsePersonDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BrowsePersonDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BrowsePersonDto" + } + } + } + } + } + } + } + }, + "/api/Person/update": { + "post": { + "tags": [ + "Person" + ], + "summary": "Updates the Person", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePersonDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePersonDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdatePersonDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + } + } + } + } + } + }, + "/api/Person/fetch-cover": { + "post": { + "tags": [ + "Person" + ], + "summary": "Attempts to download the cover from CoversDB (Note: Not yet release in Kavita)", + "parameters": [ + { + "name": "personId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "text/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/Person/series-known-for": { + "get": { + "tags": [ + "Person" + ], + "summary": "Returns the top 20 series that the \"person\" is known for. This will use Average Rating when applicable (Kavita+ field), else it's a random sort", + "parameters": [ + { + "name": "personId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + } + } + } + } + }, + "/api/Person/chapters-by-role": { + "get": { + "tags": [ + "Person" + ], + "summary": "Returns all individual chapters by role. Limited to 20 results.", + "parameters": [ + { + "name": "personId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "role", + "in": "query", + "description": "", + "schema": { + "enum": [ + 1, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StandaloneChapterDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StandaloneChapterDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StandaloneChapterDto" + } + } + } + } + } + } + } + }, "/api/Plugin/authenticate": { "post": { "tags": [ @@ -5290,6 +6006,14 @@ "schema": { "type": "string" } + }, + { + "name": "extractPdf", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -5913,7 +6637,7 @@ "tags": [ "Reader" ], - "summary": "Save page against Chapter for logged in user", + "summary": "Save page against Chapter for authenticated user", "requestBody": { "description": "", "content": { @@ -8100,7 +8824,24 @@ }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "boolean" + } + }, + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + } + } } } } @@ -8131,6 +8872,36 @@ } } }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "boolean" + } + }, + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/Scrobbling/generate-scrobble-events": { + "post": { + "tags": [ + "Scrobbling" + ], + "summary": "When a user request to generate scrobble events from history. Should only be ran once per user.", "responses": { "200": { "description": "OK" @@ -10248,6 +11019,141 @@ } } }, + "/api/Series/match": { + "post": { + "tags": [ + "Series" + ], + "summary": "Sends a request to Kavita+ API for all potential matches, sorted by relevance", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MatchSeriesDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MatchSeriesDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/MatchSeriesDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalSeriesMatchDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalSeriesMatchDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalSeriesMatchDto" + } + } + } + } + } + } + } + }, + "/api/Series/update-match": { + "post": { + "tags": [ + "Series" + ], + "summary": "This will perform the fix match", + "parameters": [ + { + "name": "seriesId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalSeriesDetailDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ExternalSeriesDetailDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ExternalSeriesDetailDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/Series/dont-match": { + "post": { + "tags": [ + "Series" + ], + "summary": "When true, will not perform a match and will prevent Kavita from attempting to match/scrobble against this series", + "parameters": [ + { + "name": "seriesId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "dontMatch", + "in": "query", + "description": "", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Server/clear-cache": { "post": { "tags": [ @@ -10274,6 +11180,19 @@ } } }, + "/api/Server/cleanup": { + "post": { + "tags": [ + "Server" + ], + "summary": "Performs the nightly maintenance work on the Server. Can be heavy.", + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Server/backup-db": { "post": { "tags": [ @@ -10300,36 +11219,6 @@ } } }, - "/api/Server/server-info": { - "get": { - "tags": [ - "Server" - ], - "summary": "Returns non-sensitive information about the current system", - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ServerInfoDto" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerInfoDto" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ServerInfoDto" - } - } - } - } - } - } - }, "/api/Server/server-info-slim": { "get": { "tags": [ @@ -10343,17 +11232,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ServerInfoDto" + "$ref": "#/components/schemas/ServerInfoSlimDto" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ServerInfoDto" + "$ref": "#/components/schemas/ServerInfoSlimDto" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ServerInfoDto" + "$ref": "#/components/schemas/ServerInfoSlimDto" } } } @@ -10470,6 +11359,18 @@ "Server" ], "summary": "Pull the Changelog for Kavita from Github and display", + "parameters": [ + { + "name": "count", + "in": "query", + "description": "How many releases from the latest to return", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + } + ], "responses": { "200": { "description": "OK", @@ -11651,45 +12552,6 @@ } } }, - "/api/Stats/kavitaplus-metadata-breakdown": { - "get": { - "tags": [ - "Stats" - ], - "summary": "Returns for Kavita+ the number of Series that have been processed, errored, and not processed", - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Int32StatCount" - } - } - }, - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Int32StatCount" - } - } - }, - "text/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Int32StatCount" - } - } - } - } - } - } - } - }, "/api/Stream/dashboard": { "get": { "tags": [ @@ -12634,7 +13496,6 @@ ], "summary": "Uploads a new theme file", "requestBody": { - "description": "", "content": { "multipart/form-data": { "schema": { @@ -12642,6 +13503,7 @@ "properties": { "formFile": { "type": "string", + "description": "", "format": "binary" } } @@ -12962,6 +13824,39 @@ "deprecated": true } }, + "/api/Upload/person": { + "post": { + "tags": [ + "Upload" + ], + "summary": "Replaces person tag cover image and locks it with a base64 encoded image", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadFileDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UploadFileDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UploadFileDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Users/delete-user": { "delete": { "tags": [ @@ -13154,7 +14049,10 @@ "tags": [ "Users" ], + "summary": "Update the user preferences", + "description": "If the user has ReadOnly role, they will not be able to perform this action", "requestBody": { + "description": "", "content": { "application/json": { "schema": { @@ -13266,6 +14164,46 @@ } } }, + "/api/Users/tokens": { + "get": { + "tags": [ + "Users" + ], + "summary": "Returns all users with tokens registered and their token information. Does not send the tokens.", + "description": "Kavita+ only", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserTokenInfo" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserTokenInfo" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserTokenInfo" + } + } + } + } + } + } + } + }, "/api/Volume": { "get": { "tags": [ @@ -14212,6 +15150,11 @@ "coverImageLocked": { "type": "boolean" }, + "itemCount": { + "type": "integer", + "description": "Number of Series in the Collection", + "format": "int32" + }, "owner": { "type": "string", "description": "Owner of the Collection", @@ -14730,7 +15673,8 @@ 5, 6, 7, - 8 + 8, + 9 ], "type": "integer", "description": "For system provided", @@ -15102,6 +16046,73 @@ }, "additionalProperties": false }, + "BrowsePersonDto": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "coverImageLocked": { + "type": "boolean" + }, + "primaryColor": { + "type": "string", + "nullable": true + }, + "secondaryColor": { + "type": "string", + "nullable": true + }, + "coverImage": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "asin": { + "type": "string", + "description": "ASIN for person", + "nullable": true + }, + "aniListId": { + "type": "integer", + "description": "https://anilist.co/staff/{AniListId}/", + "format": "int32" + }, + "malId": { + "type": "integer", + "description": "https://myanimelist.net/people/{MalId}/\r\nhttps://myanimelist.net/character/{MalId}/CharacterName", + "format": "int64" + }, + "hardcoverId": { + "type": "string", + "description": "https://hardcover.app/authors/{HardcoverId}", + "nullable": true + }, + "seriesCount": { + "type": "integer", + "description": "Number of Series this Person is the Writer for", + "format": "int32" + }, + "issueCount": { + "type": "integer", + "description": "Number or Issues this Person is the Writer for", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Used to browse writers and click in to see their series" + }, "BulkActionDto": { "type": "object", "properties": { @@ -15420,7 +16431,7 @@ "people": { "type": "array", "items": { - "$ref": "#/components/schemas/Person" + "$ref": "#/components/schemas/ChapterPeople" }, "description": "All people attached at a Chapter level. Usually Comics will have different people per issue.", "nullable": true @@ -16135,6 +17146,49 @@ "additionalProperties": false, "description": "Exclusively metadata about a given chapter" }, + "ChapterPeople": { + "required": [ + "role" + ], + "type": "object", + "properties": { + "chapterId": { + "type": "integer", + "format": "int32" + }, + "chapter": { + "$ref": "#/components/schemas/Chapter" + }, + "personId": { + "type": "integer", + "format": "int32" + }, + "person": { + "$ref": "#/components/schemas/Person" + }, + "role": { + "enum": [ + 1, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "CollectionTag": { "required": [ "normalizedTitle", @@ -16532,6 +17586,20 @@ "additionalProperties": false, "description": "For requesting an encoded filter to be decoded" }, + "DeleteChaptersDto": { + "type": "object", + "properties": { + "chapterIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "DeleteCollectionsDto": { "required": [ "collectionIds" @@ -16774,6 +17842,35 @@ }, "additionalProperties": false }, + "EmailHistoryDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "sent": { + "type": "boolean" + }, + "sendDate": { + "type": "string", + "format": "date-time" + }, + "emailTemplate": { + "type": "string", + "nullable": true + }, + "errorMessage": { + "type": "string", + "nullable": true + }, + "toUserName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "EmailTestResultDto": { "type": "object", "properties": { @@ -16977,6 +18074,98 @@ "additionalProperties": false, "description": "Represents an Externally supplied Review for a given Series" }, + "ExternalSeriesDetailDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "aniListId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "malId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "plusMediaFormat": { + "enum": [ + 1, + 2, + 3, + 4, + 5 + ], + "type": "integer", + "description": "Represents PlusMediaFormat", + "format": "int32" + }, + "siteUrl": { + "type": "string", + "nullable": true + }, + "coverUrl": { + "type": "string", + "nullable": true + }, + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "staff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesStaffDto" + }, + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetadataTagDto" + }, + "nullable": true + }, + "summary": { + "type": "string", + "nullable": true + }, + "volumeCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "chapterCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "provider": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "description": "Misleading name but is the source of data (like a review coming from AniList)", + "format": "int32" + } + }, + "additionalProperties": false + }, "ExternalSeriesDto": { "required": [ "coverUrl", @@ -17024,6 +18213,19 @@ }, "additionalProperties": false }, + "ExternalSeriesMatchDto": { + "type": "object", + "properties": { + "series": { + "$ref": "#/components/schemas/ExternalSeriesDetailDto" + }, + "matchRating": { + "type": "number", + "format": "float" + } + }, + "additionalProperties": false + }, "ExternalSeriesMetadata": { "type": "object", "properties": { @@ -17056,7 +18258,7 @@ }, "averageExternalRating": { "type": "integer", - "description": "Average External Rating. -1 means not set", + "description": "Average External Rating. -1 means not set, 0 - 100", "format": "int32" }, "aniListId": { @@ -17190,33 +18392,6 @@ }, "additionalProperties": false }, - "FileFormatDto": { - "required": [ - "extension", - "format" - ], - "type": "object", - "properties": { - "extension": { - "type": "string", - "description": "The extension with the ., in lowercase", - "nullable": true - }, - "format": { - "enum": [ - 0, - 1, - 2, - 3, - 4 - ], - "type": "integer", - "description": "Format of extension", - "format": "int32" - } - }, - "additionalProperties": false - }, "FilterDto": { "type": "object", "properties": { @@ -17496,7 +18671,8 @@ 28, 29, 30, - 31 + 31, + 32 ], "type": "integer", "description": "Represents the field which will dictate the value type and the Extension used for filtering", @@ -18135,6 +19311,43 @@ }, "additionalProperties": false }, + "LicenseInfoDto": { + "type": "object", + "properties": { + "expirationDate": { + "type": "string", + "description": "If cancelled, will represent cancellation date. If not, will represent repayment date", + "format": "date-time" + }, + "isActive": { + "type": "boolean", + "description": "If cancelled or not" + }, + "isCancelled": { + "type": "boolean", + "description": "If will be or is cancelled" + }, + "isValidVersion": { + "type": "boolean", + "description": "Is the installed version valid for Kavita+ (aka within 3 releases)" + }, + "registeredEmail": { + "type": "string", + "description": "The email on file", + "nullable": true + }, + "totalMonthsSubbed": { + "type": "integer", + "description": "Number of months user has been subscribed", + "format": "int32" + }, + "hasLicense": { + "type": "boolean", + "description": "A license is stored within Kavita" + } + }, + "additionalProperties": false + }, "LoginDto": { "type": "object", "properties": { @@ -18218,6 +19431,44 @@ "additionalProperties": false, "description": "Information about a User's MAL connection" }, + "ManageMatchFilterDto": { + "type": "object", + "properties": { + "matchStateOption": { + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "type": "integer", + "description": "Represents an option in the UI layer for Filtering", + "format": "int32" + }, + "searchTerm": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ManageMatchSeriesDto": { + "type": "object", + "properties": { + "series": { + "$ref": "#/components/schemas/SeriesDto" + }, + "isMatched": { + "type": "boolean" + }, + "validUntilUtc": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, "MangaFile": { "required": [ "filePath" @@ -18437,6 +19688,27 @@ "additionalProperties": false, "description": "This is used for bulk updating a set of volume and or chapters in one go" }, + "MatchSeriesDto": { + "type": "object", + "properties": { + "dontMatch": { + "type": "boolean", + "description": "When set, Kavita will stop attempting to match this series and will not perform any scrobbling" + }, + "seriesId": { + "type": "integer", + "description": "Series Id to pull internal metadata from to improve matching", + "format": "int32" + }, + "query": { + "type": "string", + "description": "Free form text to query for. Can be a url and ids will be parsed from it", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Used for matching a series with Kavita+ for metadata and scrobbling" + }, "MediaErrorDto": { "required": [ "extension", @@ -18527,6 +19799,39 @@ "additionalProperties": false, "description": "Represents a member of a Kavita server." }, + "MetadataTagDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true, + "readOnly": true + }, + "rank": { + "type": "integer", + "format": "int32", + "nullable": true, + "readOnly": true + }, + "isGeneralSpoiler": { + "type": "boolean", + "readOnly": true + }, + "isMediaSpoiler": { + "type": "boolean", + "readOnly": true + }, + "isAdult": { + "type": "boolean", + "readOnly": true + } + }, + "additionalProperties": false + }, "NextExpectedChapterDto": { "type": "object", "properties": { @@ -18555,8 +19860,7 @@ "Person": { "required": [ "name", - "normalizedName", - "role" + "normalizedName" ], "type": "object", "properties": { @@ -18572,37 +19876,57 @@ "type": "string", "nullable": true }, - "role": { - "enum": [ - 1, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15 - ], - "type": "integer", - "format": "int32" - }, - "seriesMetadatas": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeriesMetadata" - }, + "coverImage": { + "type": "string", "nullable": true }, - "chapterMetadatas": { + "coverImageLocked": { + "type": "boolean" + }, + "primaryColor": { + "type": "string", + "nullable": true + }, + "secondaryColor": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "asin": { + "type": "string", + "description": "ASIN for person", + "nullable": true + }, + "aniListId": { + "type": "integer", + "description": "https://anilist.co/staff/{AniListId}/", + "format": "int32" + }, + "malId": { + "type": "integer", + "description": "https://myanimelist.net/people/{MalId}/\r\nhttps://myanimelist.net/character/{MalId}/CharacterName", + "format": "int64" + }, + "hardcoverId": { + "type": "string", + "description": "https://hardcover.app/authors/{HardcoverId}", + "nullable": true + }, + "chapterPeople": { "type": "array", "items": { - "$ref": "#/components/schemas/Chapter" + "$ref": "#/components/schemas/ChapterPeople" + }, + "description": "https://metron.cloud/creator/{slug}/", + "nullable": true + }, + "seriesMetadataPeople": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesMetadataPeople" }, "nullable": true } @@ -18623,25 +19947,44 @@ "type": "string", "nullable": true }, - "role": { - "enum": [ - 1, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15 - ], + "coverImageLocked": { + "type": "boolean" + }, + "primaryColor": { + "type": "string", + "nullable": true + }, + "secondaryColor": { + "type": "string", + "nullable": true + }, + "coverImage": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "asin": { + "type": "string", + "description": "ASIN for person", + "nullable": true + }, + "aniListId": { "type": "integer", + "description": "https://anilist.co/staff/{AniListId}/", "format": "int32" + }, + "malId": { + "type": "integer", + "description": "https://myanimelist.net/people/{MalId}/\r\nhttps://myanimelist.net/character/{MalId}/CharacterName", + "format": "int64" + }, + "hardcoverId": { + "type": "string", + "description": "https://hardcover.app/authors/{HardcoverId}", + "nullable": true } }, "additionalProperties": false @@ -19014,6 +20357,11 @@ "type": "string", "nullable": true }, + "itemCount": { + "type": "integer", + "description": "Number of Items in the Reading List", + "format": "int32" + }, "startingYear": { "type": "integer", "description": "Minimum Year the Reading List starts", @@ -19187,6 +20535,9 @@ "type": "string", "description": "The chapter summary", "nullable": true + }, + "isSpecial": { + "type": "boolean" } }, "additionalProperties": false @@ -19981,6 +21332,14 @@ "type": "number", "format": "float" }, + "dontMatch": { + "type": "boolean", + "description": "Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling." + }, + "isBlacklisted": { + "type": "boolean", + "description": "If the series was unable to match, it will be blacklisted until a manual metadata match overrides it" + }, "metadata": { "$ref": "#/components/schemas/SeriesMetadata" }, @@ -20246,6 +21605,14 @@ "description": "The last time the folder for this series was scanned", "format": "date-time" }, + "dontMatch": { + "type": "boolean", + "description": "Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling." + }, + "isBlacklisted": { + "type": "boolean", + "description": "If the series was unable to match, it will be blacklisted until a manual metadata match overrides it" + }, "coverImage": { "type": "string", "nullable": true @@ -20285,36 +21652,6 @@ "type": "string", "nullable": true }, - "collectionTags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CollectionTag" - }, - "nullable": true, - "deprecated": true - }, - "genres": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Genre" - }, - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Tag" - }, - "nullable": true - }, - "people": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Person" - }, - "description": "All people attached at a Series level.", - "nullable": true - }, "ageRating": { "enum": [ 0, @@ -20436,13 +21773,43 @@ "releaseYearLocked": { "type": "boolean" }, - "series": { - "$ref": "#/components/schemas/Series" + "collectionTags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionTag" + }, + "nullable": true, + "deprecated": true + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Genre" + }, + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + }, + "nullable": true + }, + "people": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesMetadataPeople" + }, + "description": "All people attached at a Series level.", + "nullable": true }, "seriesId": { "type": "integer", "format": "int32" }, + "series": { + "$ref": "#/components/schemas/Series" + }, "rowVersion": { "type": "integer", "format": "int32", @@ -20698,6 +22065,49 @@ }, "additionalProperties": false }, + "SeriesMetadataPeople": { + "required": [ + "role" + ], + "type": "object", + "properties": { + "seriesMetadataId": { + "type": "integer", + "format": "int32" + }, + "seriesMetadata": { + "$ref": "#/components/schemas/SeriesMetadata" + }, + "personId": { + "type": "integer", + "format": "int32" + }, + "person": { + "$ref": "#/components/schemas/Person" + }, + "role": { + "enum": [ + 1, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "SeriesRelation": { "type": "object", "properties": { @@ -20745,16 +22155,45 @@ "additionalProperties": false, "description": "A relation flows between one series and another.\r\nSeries ---kind---> target" }, - "ServerInfoDto": { + "SeriesStaffDto": { + "required": [ + "name", + "role", + "url" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "nullable": true + }, + "role": { + "type": "string", + "nullable": true + }, + "imageUrl": { + "type": "string", + "nullable": true + }, + "gender": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ServerInfoSlimDto": { "required": [ - "dotnetVersion", - "fileFormats", "installId", - "kavitaVersion", - "mangaReaderBackgroundColors", - "mangaReaderLayoutModes", - "mangaReaderPageSplittingModes", - "os" + "kavitaVersion" ], "type": "object", "properties": { @@ -20763,213 +22202,29 @@ "description": "Unique Id that represents a unique install", "nullable": true }, - "os": { - "type": "string", - "nullable": true - }, "isDocker": { "type": "boolean", "description": "If the Kavita install is using Docker" }, - "dotnetVersion": { - "type": "string", - "description": "Version of .NET instance is running", - "nullable": true - }, "kavitaVersion": { "type": "string", "description": "Version of Kavita", "nullable": true }, - "numOfCores": { - "type": "integer", - "description": "Number of Cores on the instance", - "format": "int32" - }, - "numberOfLibraries": { - "type": "integer", - "description": "The number of libraries on the instance", - "format": "int32" - }, - "hasBookmarks": { - "type": "boolean", - "description": "Does any user have bookmarks" - }, - "activeSiteTheme": { + "firstInstallDate": { "type": "string", - "description": "The site theme the install is using", + "description": "The Date Kavita was first installed", + "format": "date-time", "nullable": true }, - "mangaReaderMode": { - "enum": [ - 0, - 1, - 2 - ], - "type": "integer", - "description": "The reading mode the main user has as a preference", - "format": "int32" - }, - "numberOfUsers": { - "type": "integer", - "description": "Number of users on the install", - "format": "int32" - }, - "numberOfCollections": { - "type": "integer", - "description": "Number of collections on the install", - "format": "int32" - }, - "numberOfReadingLists": { - "type": "integer", - "description": "Number of reading lists on the install (Sum of all users)", - "format": "int32" - }, - "opdsEnabled": { - "type": "boolean", - "description": "Is OPDS enabled" - }, - "totalFiles": { - "type": "integer", - "description": "Total number of files in the instance", - "format": "int32" - }, - "totalGenres": { - "type": "integer", - "description": "Total number of Genres in the instance", - "format": "int32" - }, - "totalPeople": { - "type": "integer", - "description": "Total number of People in the instance", - "format": "int32" - }, - "usersOnCardLayout": { - "type": "integer", - "description": "Number of users on this instance using Card Layout", - "format": "int32" - }, - "usersOnListLayout": { - "type": "integer", - "description": "Number of users on this instance using List Layout", - "format": "int32" - }, - "maxSeriesInALibrary": { - "type": "integer", - "description": "Max number of Series for any library on the instance", - "format": "int32" - }, - "maxVolumesInASeries": { - "type": "integer", - "description": "Max number of Volumes for any library on the instance", - "format": "int32" - }, - "maxChaptersInASeries": { - "type": "integer", - "description": "Max number of Chapters for any library on the instance", - "format": "int32" - }, - "usingSeriesRelationships": { - "type": "boolean", - "description": "Does this instance have relationships setup between series" - }, - "mangaReaderBackgroundColors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "A list of background colors set on the instance", - "nullable": true - }, - "mangaReaderPageSplittingModes": { - "type": "array", - "items": { - "enum": [ - 0, - 1, - 2, - 3 - ], - "type": "integer", - "format": "int32" - }, - "description": "A list of Page Split defaults being used on the instance", - "nullable": true - }, - "mangaReaderLayoutModes": { - "type": "array", - "items": { - "enum": [ - 1, - 2, - 3 - ], - "type": "integer", - "format": "int32" - }, - "description": "A list of Layout Mode defaults being used on the instance", - "nullable": true - }, - "fileFormats": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileFormatDto" - }, - "description": "A list of file formats existing in the instance", - "nullable": true - }, - "usingRestrictedProfiles": { - "type": "boolean", - "description": "If there is at least one user that is using an age restricted profile on the instance" - }, - "usersWithEmulateComicBook": { - "type": "integer", - "description": "Number of users using the Emulate Comic Book setting", - "format": "int32" - }, - "percentOfLibrariesWithFolderWatchingEnabled": { - "type": "number", - "description": "Percent (0.0-1.0) of libraries with folder watching enabled", - "format": "float" - }, - "percentOfLibrariesIncludedInSearch": { - "type": "number", - "description": "Percent (0.0-1.0) of libraries included in Search", - "format": "float" - }, - "percentOfLibrariesIncludedInRecommended": { - "type": "number", - "description": "Percent (0.0-1.0) of libraries included in Recommended", - "format": "float" - }, - "percentOfLibrariesIncludedInDashboard": { - "type": "number", - "description": "Percent (0.0-1.0) of libraries included in Dashboard", - "format": "float" - }, - "totalReadingHours": { - "type": "integer", - "description": "Total reading hours of all users", - "format": "int64" - }, - "encodeMediaAs": { - "enum": [ - 0, - 1, - 2 - ], - "type": "integer", - "description": "The encoding the server is using to save media", - "format": "int32" - }, - "lastReadTime": { + "firstInstallVersion": { "type": "string", - "description": "The last user reading progress on the server (in UTC)", - "format": "date-time" + "description": "The Version of Kavita on the first run", + "nullable": true } }, "additionalProperties": false, - "description": "Represents information about a Kavita Installation" + "description": "This is just for the Server tab on UI" }, "ServerSettingDto": { "type": "object", @@ -21233,7 +22488,8 @@ 5, 6, 7, - 8 + 8, + 9 ], "type": "integer", "description": "For system provided", @@ -21515,6 +22771,397 @@ "additionalProperties": false, "description": "Sorting Options for a query" }, + "StandaloneChapterDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "range": { + "type": "string", + "description": "Range of chapters. Chapter 2-4 -> \"2-4\". Chapter 2 -> \"2\". If special, will be special name.", + "nullable": true + }, + "number": { + "type": "string", + "description": "Smallest number of the Range.", + "nullable": true, + "deprecated": true + }, + "minNumber": { + "type": "number", + "description": "This may be 0 under the circumstance that the Issue is \"Alpha\" or other non-standard numbers.", + "format": "float" + }, + "maxNumber": { + "type": "number", + "format": "float" + }, + "sortOrder": { + "type": "number", + "description": "The sorting order of the Chapter. Inherits from MinNumber, but can be overridden.", + "format": "float" + }, + "pages": { + "type": "integer", + "description": "Total number of pages in all MangaFiles", + "format": "int32" + }, + "isSpecial": { + "type": "boolean", + "description": "If this Chapter contains files that could only be identified as Series or has Special Identifier from filename" + }, + "title": { + "type": "string", + "description": "Used for books/specials to display custom title. For non-specials/books, will be set to API.DTOs.ChapterDto.Range", + "nullable": true + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MangaFileDto" + }, + "description": "The files that represent this Chapter", + "nullable": true + }, + "pagesRead": { + "type": "integer", + "description": "Calculated at API time. Number of pages read for this Chapter for logged in user.", + "format": "int32" + }, + "lastReadingProgressUtc": { + "type": "string", + "description": "The last time a chapter was read by current authenticated user", + "format": "date-time" + }, + "lastReadingProgress": { + "type": "string", + "description": "The last time a chapter was read by current authenticated user", + "format": "date-time" + }, + "coverImageLocked": { + "type": "boolean", + "description": "If the Cover Image is locked for this entity" + }, + "volumeId": { + "type": "integer", + "description": "Volume Id this Chapter belongs to", + "format": "int32" + }, + "createdUtc": { + "type": "string", + "description": "When chapter was created", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, + "created": { + "type": "string", + "description": "When chapter was created in local server time", + "format": "date-time" + }, + "releaseDate": { + "type": "string", + "description": "When the chapter was released.", + "format": "date-time" + }, + "titleName": { + "type": "string", + "description": "Title of the Chapter/Issue", + "nullable": true + }, + "summary": { + "type": "string", + "description": "Summary of the Chapter", + "nullable": true + }, + "ageRating": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + -1 + ], + "type": "integer", + "description": "Age Rating for the issue/chapter", + "format": "int32" + }, + "wordCount": { + "type": "integer", + "description": "Total words in a Chapter (books only)", + "format": "int64" + }, + "minHoursToRead": { + "type": "integer", + "format": "int32" + }, + "maxHoursToRead": { + "type": "integer", + "format": "int32" + }, + "avgHoursToRead": { + "type": "number", + "format": "float" + }, + "webLinks": { + "type": "string", + "description": "Comma-separated link of urls to external services that have some relation to the Chapter", + "nullable": true + }, + "isbn": { + "type": "string", + "description": "ISBN-13 (usually) of the Chapter", + "nullable": true + }, + "writers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "coverArtists": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "publishers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "characters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "pencillers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "inkers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "imprints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "colorists": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "letterers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "editors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "translators": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "teams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenreTagDto" + }, + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagDto" + }, + "description": "Collection of all Tags from underlying chapters for a Series", + "nullable": true + }, + "publicationStatus": { + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "type": "integer", + "format": "int32" + }, + "language": { + "type": "string", + "description": "Language for the Chapter/Issue", + "nullable": true + }, + "count": { + "type": "integer", + "description": "Number in the TotalCount of issues", + "format": "int32" + }, + "totalCount": { + "type": "integer", + "description": "Total number of issues for the series", + "format": "int32" + }, + "languageLocked": { + "type": "boolean" + }, + "summaryLocked": { + "type": "boolean" + }, + "ageRatingLocked": { + "type": "boolean", + "description": "Locked by user so metadata updates from scan loop will not override AgeRating" + }, + "publicationStatusLocked": { + "type": "boolean", + "description": "Locked by user so metadata updates from scan loop will not override PublicationStatus" + }, + "genresLocked": { + "type": "boolean" + }, + "tagsLocked": { + "type": "boolean" + }, + "writerLocked": { + "type": "boolean" + }, + "characterLocked": { + "type": "boolean" + }, + "coloristLocked": { + "type": "boolean" + }, + "editorLocked": { + "type": "boolean" + }, + "inkerLocked": { + "type": "boolean" + }, + "imprintLocked": { + "type": "boolean" + }, + "lettererLocked": { + "type": "boolean" + }, + "pencillerLocked": { + "type": "boolean" + }, + "publisherLocked": { + "type": "boolean" + }, + "translatorLocked": { + "type": "boolean" + }, + "teamLocked": { + "type": "boolean" + }, + "locationLocked": { + "type": "boolean" + }, + "coverArtistLocked": { + "type": "boolean" + }, + "releaseYearLocked": { + "type": "boolean" + }, + "coverImage": { + "type": "string", + "nullable": true + }, + "primaryColor": { + "type": "string", + "nullable": true + }, + "secondaryColor": { + "type": "string", + "nullable": true + }, + "seriesId": { + "type": "integer", + "format": "int32" + }, + "libraryId": { + "type": "integer", + "format": "int32" + }, + "libraryType": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "type": "integer", + "format": "int32" + }, + "volumeTitle": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Used on Person Profile page" + }, "Tag": { "required": [ "normalizedTitle", @@ -22138,11 +23785,109 @@ "isReleaseEqual": { "type": "boolean", "description": "Is the server on this version" + }, + "added": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "removed": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "changed": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "fixed": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "theme": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "developer": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "api": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "blogPart": { + "type": "string", + "description": "The part above the changelog part", + "nullable": true } }, "additionalProperties": false, "description": "Update Notification denoting a new release available for user to update to" }, + "UpdatePersonDto": { + "required": [ + "coverImageLocked", + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "coverImageLocked": { + "type": "boolean" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "aniListId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "malId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "hardcoverId": { + "type": "string", + "nullable": true + }, + "asin": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "UpdateReadingListByChapterDto": { "type": "object", "properties": { @@ -22544,6 +24289,11 @@ }, "ageRestriction": { "$ref": "#/components/schemas/AgeRestrictionDto" + }, + "email": { + "type": "string", + "description": "Email of the user", + "nullable": true } }, "additionalProperties": false @@ -23042,6 +24792,34 @@ "additionalProperties": false, "description": "Represents a User Review for a given Series" }, + "UserTokenInfo": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "format": "int32" + }, + "username": { + "type": "string", + "nullable": true + }, + "isAniListTokenSet": { + "type": "boolean" + }, + "isAniListTokenValid": { + "type": "boolean" + }, + "aniListValidUntilUtc": { + "type": "string", + "format": "date-time" + }, + "isMalTokenSet": { + "type": "boolean" + } + }, + "additionalProperties": false, + "description": "Represents information around a user's tokens and their status" + }, "Volume": { "required": [ "maxNumber", @@ -23283,6 +25061,10 @@ "name": "Image", "description": "Responsible for servicing up images stored in Kavita for entities" }, + { + "name": "Manage", + "description": "All things centered around Managing the Kavita instance, that isn't aligned with an entity" + }, { "name": "Panels", "description": "For the Panels app explicitly"