Kavita+ Overhaul & New Changelog (#3507)

This commit is contained in:
Joe Milazzo 2025-01-20 08:14:57 -06:00 committed by GitHub
parent d880c1690c
commit a5707617f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
249 changed files with 14775 additions and 2300 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -9,7 +9,7 @@ on:
jobs:
check_pr:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Extract branch name
shell: bash

View File

@ -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

1
.gitignore vendored
View File

@ -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

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
</PropertyGroup>

View File

@ -1,22 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.1.3" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.1.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.2.1" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.2.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.collector" Version="6.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -2,7 +2,7 @@
<PropertyGroup>
<AnalysisMode>Default</AnalysisMode>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<TieredPGO>true</TieredPGO>
@ -12,10 +12,10 @@
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<!-- <Delete Files="../openapi.json" />-->
<!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
<!-- </Target>-->
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
<Delete Files="../openapi.json" />
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
</Target>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
@ -55,8 +55,8 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<PackageReference Include="MailKit" Version="4.9.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -64,49 +64,49 @@
<PackageReference Include="Docnet.Core" Version="2.6.0" />
<PackageReference Include="EasyCaching.InMemory" Version="1.9.2" />
<PackageReference Include="ExCSS" Version="4.3.0" />
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.15" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Hangfire" Version="1.8.17" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.70" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.72" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.15" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.17" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.1" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="3.0.0" />
<PackageReference Include="NetVips.Native" Version="8.16.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="NReco.Logging.File" Version="1.2.2" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.38.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.3.0.106239">
<PackageReference Include="SharpCompress" Version="0.39.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.5.0.109200">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.0" />
<PackageReference Include="System.IO.Abstractions" Version="21.1.3" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
<PackageReference Include="System.IO.Abstractions" Version="21.2.1" />
<PackageReference Include="System.Drawing.Common" Version="9.0.1" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
<PackageReference Include="YamlDotNet" Version="16.1.3" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<ItemGroup>
@ -192,6 +192,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="config\cache-long\" />
<Folder Include="config\themes" />
<Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View File

@ -12,6 +12,10 @@ public static class EasyCacheProfiles
/// </summary>
public const string License = "license";
/// <summary>
/// License Information
/// </summary>
public const string LicenseInfo = "licenseInfo";
/// <summary>
/// Cache the libraries on the server
/// </summary>
public const string Library = "library";

View File

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

View File

@ -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<AppUser> _userManager;
private readonly IUnitOfWork _unitOfWork;
public AdminController(UserManager<AppUser> userManager)
public AdminController(UserManager<AppUser> userManager, IUnitOfWork unitOfWork)
{
_userManager = userManager;
_unitOfWork = unitOfWork;
}
/// <summary>

View File

@ -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<ActionResult<IList<EmailHistoryDto>>> GetEmails()
{
return Ok(await _unitOfWork.EmailHistoryRepository.GetEmailDtos(UserParams.Default));
}
}

View File

@ -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(
}
/// <summary>
/// Has any license
/// Has any license registered with the instance. Does not check Kavita+ API
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
@ -53,6 +54,19 @@ public class LicenseController(
(await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value));
}
/// <summary>
/// Asks Kavita+ for the latest license info
/// </summary>
/// <param name="forceCheck">Force checking the API and skip the 8 hour cache</param>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
public async Task<ActionResult<LicenseInfoDto?>> 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<ActionResult> ResetLicense(UpdateLicenseDto dto)

View File

@ -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;
/// <summary>
/// All things centered around Managing the Kavita instance, that isn't aligned with an entity
/// </summary>
[Authorize("RequireAdminRole")]
public class ManageController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILicenseService _licenseService;
public ManageController(IUnitOfWork unitOfWork, ILicenseService licenseService)
{
_unitOfWork = unitOfWork;
_licenseService = licenseService;
}
/// <summary>
/// Returns a list of all Series that is Kavita+ applicable to metadata match and the status of it
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpPost("series-metadata")]
public async Task<ActionResult<IList<ManageMatchSeriesDto>>> SeriesMetadata(ManageMatchFilterDto filter)
{
if (!await _licenseService.HasActiveLicense()) return Ok(Array.Empty<SeriesDto>());
return Ok(await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeries(filter));
}
}

View File

@ -32,12 +32,15 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
/// Fetches genres from the instance
/// </summary>
/// <param name="libraryIds">String separated libraryIds or null for all genres</param>
/// <param name="context">Context from which this API was invoked</param>
/// <returns></returns>
[HttpGet("genres")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds", "context"])]
public async Task<ActionResult<IList<GenreTagDto>>> 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
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("force-refresh")]
public async Task<ActionResult> ForceRefresh(int seriesId)
{
await metadataService.ForceKavitaPlusRefresh(seriesId);
return Ok();
}
// [HttpPost("force-refresh")]
// public async Task<ActionResult> ForceRefresh(int seriesId)
// {
// await metadataService.ForceKavitaPlusRefresh(seriesId);
// return Ok();
// }
/// <summary>
/// Fetches the details needed from Kavita+ for Series Detail page

View File

@ -764,6 +764,12 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
/// <summary>
/// OPDS Search endpoint
/// </summary>
/// <param name="apiKey"></param>
/// <param name="query"></param>
/// <returns></returns>
[HttpGet("{apiKey}/series")]
[Produces("application/xml")]
public async Task<IActionResult> 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));
}

View File

@ -73,7 +73,7 @@ public class PersonController : BaseApiController
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize("AdminRequired")]
[Authorize("RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult<PersonDto>> 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);

View File

@ -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
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
/// <returns>True if the token was new or not</returns>
[HttpPost("update-anilist-token")]
public async Task<ActionResult> UpdateAniListToken(AniListUpdateDto dto)
public async Task<ActionResult<bool>> 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);
}
/// <summary>
/// Update the current user's MAL token (Client ID) and Username
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
/// <returns>True if the token was new or not</returns>
[HttpPost("update-mal-token")]
public async Task<ActionResult> UpdateMalToken(MalUserInfoDto dto)
public async Task<ActionResult<bool>> 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);
}
/// <summary>
/// When a user request to generate scrobble events from history. Should only be ran once per user.
/// </summary>
/// <returns></returns>
[HttpPost("generate-scrobble-events")]
public ActionResult GenerateScrobbleEvents()
{
BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(User.GetUserId()));
return Ok();
}

View File

@ -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));
}
/// <summary>
/// Sends a request to Kavita+ API for all potential matches, sorted by relevance
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("match")]
public async Task<ActionResult<IList<ExternalSeriesMatchDto>>> MatchSeries(MatchSeriesDto dto)
{
return Ok(await _externalMetadataService.MatchSeries(dto));
}
/// <summary>
/// This will perform the fix match
/// </summary>
/// <param name="dto"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("update-match")]
public async Task<ActionResult> UpdateSeriesMatch(ExternalSeriesDetailDto dto, [FromQuery] int seriesId)
{
await _externalMetadataService.FixSeriesMatch(seriesId, dto);
return Ok();
}
/// <summary>
/// When true, will not perform a match and will prevent Kavita from attempting to match/scrobble against this series
/// </summary>
/// <param name="seriesId"></param>
/// <param name="dontMatch"></param>
/// <returns></returns>
[HttpPost("dont-match")]
public async Task<ActionResult> UpdateDontMatch([FromQuery] int seriesId, [FromQuery] bool dontMatch)
{
await _externalMetadataService.UpdateSeriesDontMatch(seriesId, dontMatch);
return Ok();
}
}

View File

@ -213,15 +213,16 @@ public class ServerController : BaseApiController
/// <summary>
/// Pull the Changelog for Kavita from Github and display
/// </summary>
/// <param name="count">How many releases from the latest to return</param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("changelog")]
public async Task<ActionResult<IEnumerable<UpdateNotificationDto>>> GetChangelog()
public async Task<ActionResult<IEnumerable<UpdateNotificationDto>>> 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));
}
/// <summary>

View File

@ -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);

View File

@ -222,18 +222,4 @@ public class StatsController : BaseApiController
return Ok(_statService.GetWordsReadCountByYear(userId));
}
/// <summary>
/// Returns for Kavita+ the number of Series that have been processed, errored, and not processed
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("kavitaplus-metadata-breakdown")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetKavitaPlusMetadataBreakdown()
{
if (!await _licenseService.HasActiveLicense())
return BadRequest("This data is not available for non-Kavita+ servers");
return Ok(await _statService.GetKavitaPlusMetadataBreakdown());
}
}

View File

@ -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));
}
/// <summary>
/// Returns all users with tokens registered and their token information. Does not send the tokens.
/// </summary>
/// <remarks>Kavita+ only</remarks>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("tokens")]
public async Task<ActionResult<IEnumerable<UserTokenInfo>>> GetUserTokens()
{
if (!await _licenseService.HasActiveLicense()) return BadRequest(_localizationService.Translate(User.GetUserId(), "kavitaplus-restricted"));
return Ok((await _unitOfWork.UserRepository.GetUserTokenInfo()));
}
}

View File

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

View File

@ -1,4 +1,4 @@
namespace API.DTOs.Account;
namespace API.DTOs.KavitaPlus.Account;
public class AniListUpdateDto
{

View File

@ -0,0 +1,16 @@
using System;
namespace API.DTOs.KavitaPlus.Account;
/// <summary>
/// Represents information around a user's tokens and their status
/// </summary>
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; }
}

View File

@ -0,0 +1,16 @@
using API.DTOs.Scrobbling;
namespace API.DTOs.KavitaPlus.ExternalMetadata;
/// <summary>
/// Used for matching and fetching metadata on a series
/// </summary>
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;
}

View File

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

View File

@ -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<MediaRecommendationDto> Recommendations { get; set; }
public IEnumerable<UserReviewDto> Reviews { get; set; }
public IEnumerable<RatingDto> Ratings { get; set; }
public int? AniListId { get; set; }
public long? MalId { get; set; }
}

View File

@ -1,4 +1,4 @@
namespace API.DTOs.License;
namespace API.DTOs.KavitaPlus.License;
public class EncryptLicenseDto
{

View File

@ -0,0 +1,35 @@
using System;
namespace API.DTOs.KavitaPlus.License;
public class LicenseInfoDto
{
/// <summary>
/// If cancelled, will represent cancellation date. If not, will represent repayment date
/// </summary>
public DateTime ExpirationDate { get; set; }
/// <summary>
/// If cancelled or not
/// </summary>
public bool IsActive { get; set; }
/// <summary>
/// If will be or is cancelled
/// </summary>
public bool IsCancelled { get; set; }
/// <summary>
/// Is the installed version valid for Kavita+ (aka within 3 releases)
/// </summary>
public bool IsValidVersion { get; set; }
/// <summary>
/// The email on file
/// </summary>
public string RegisteredEmail { get; set; }
/// <summary>
/// Number of months user has been subscribed
/// </summary>
public int TotalMonthsSubbed { get; set; }
/// <summary>
/// A license is stored within Kavita
/// </summary>
public bool HasLicense { get; set; }
}

View File

@ -1,4 +1,4 @@
namespace API.DTOs.Account;
namespace API.DTOs.KavitaPlus.License;
public class LicenseValidDto
{

View File

@ -1,4 +1,4 @@
namespace API.DTOs.License;
namespace API.DTOs.KavitaPlus.License;
public class ResetLicenseDto
{

View File

@ -1,4 +1,4 @@
namespace API.DTOs.License;
namespace API.DTOs.KavitaPlus.License;
public class UpdateLicenseDto
{

View File

@ -0,0 +1,19 @@
namespace API.DTOs.KavitaPlus.Manage;
/// <summary>
/// Represents an option in the UI layer for Filtering
/// </summary>
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;
}

View File

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

View File

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

View File

@ -0,0 +1,20 @@
namespace API.DTOs.Metadata.Matching;
/// <summary>
/// Used for matching a series with Kavita+ for metadata and scrobbling
/// </summary>
public class MatchSeriesDto
{
/// <summary>
/// When set, Kavita will stop attempting to match this series and will not perform any scrobbling
/// </summary>
public bool DontMatch { get; set; }
/// <summary>
/// Series Id to pull internal metadata from to improve matching
/// </summary>
public int SeriesId { get; set; }
/// <summary>
/// Free form text to query for. Can be a url and ids will be parsed from it
/// </summary>
public string Query { get; set; }
}

View File

@ -11,7 +11,7 @@ public class ExternalSeriesDetailDto
public int? AniListId { get; set; }
public long? MALId { get; set; }
public IList<string> 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<string> Genres { get; set; }

View File

@ -12,4 +12,6 @@ public class ExternalSeriesDto
public int? AniListId { get; set; }
public long? MalId { get; set; }
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList;
}

View File

@ -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; }
/// <summary>
/// Optional but can help with matching
/// </summary>

View File

@ -22,7 +22,7 @@ public enum ScrobbleEventType
/// <summary>
/// Represents PlusMediaFormat
/// </summary>
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; }
/// <summary>
/// Optional AniListId if present on Kavita's WebLinks

View File

@ -67,6 +67,16 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage
/// The last time the folder for this series was scanned
/// </summary>
public DateTime LastFolderScanned { get; set; }
#region KavitaPlus
/// <summary>
/// Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling.
/// </summary>
public bool DontMatch { get; set; }
/// <summary>
/// If the series was unable to match, it will be blacklisted until a manual metadata match overrides it
/// </summary>
public bool IsBlacklisted { get; set; }
#endregion
public string? CoverImage { get; set; }
public string PrimaryColor { get; set; }

View File

@ -1,4 +1,7 @@
namespace API.DTOs.Update;
using System.Collections.Generic;
using System.Runtime.InteropServices.JavaScript;
namespace API.DTOs.Update;
/// <summary>
/// Update Notification denoting a new release available for user to update to
@ -21,11 +24,11 @@ public class UpdateNotificationDto
/// <summary>
/// Title of the release
/// </summary>
public required string UpdateTitle { get; init; }
public required string UpdateTitle { get; set; }
/// <summary>
/// Github Url
/// </summary>
public required string UpdateUrl { get; init; }
public required string UpdateUrl { get; set; }
/// <summary>
/// If this install is within Docker
/// </summary>
@ -37,7 +40,8 @@ public class UpdateNotificationDto
/// <summary>
/// Date of the publish
/// </summary>
public required string PublishDate { get; init; }
public required string PublishDate { get; set
; }
/// <summary>
/// Is the server on a nightly within this release
/// </summary>
@ -50,4 +54,16 @@ public class UpdateNotificationDto
/// Is the server on this version
/// </summary>
public bool IsReleaseEqual { get; set; }
public IList<string> Added { get; set; }
public IList<string> Removed { get; set; }
public IList<string> Changed { get; set; }
public IList<string> Fixed { get; set; }
public IList<string> Theme { get; set; }
public IList<string> Developer { get; set; }
public IList<string> Api { get; set; }
/// <summary>
/// The part above the changelog part
/// </summary>
public string BlogPart { get; set; }
}

View File

@ -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<AppUser, AppRole, int,
public DbSet<AppUserCollection> AppUserCollection { get; set; } = null!;
public DbSet<ChapterPeople> ChapterPeople { get; set; } = null!;
public DbSet<SeriesMetadataPeople> SeriesMetadataPeople { get; set; } = null!;
public DbSet<EmailHistory> EmailHistory { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)

View File

@ -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;
/// <summary>
/// v0.8.5 - Migrating Kavita+ BlacklistedSeries table to Series entity to streamline implementation and generate a "Needs Manual Match" entry for the Series
/// </summary>
public static class ManualMigrateBlacklistTableToSeries
{
public static async Task Migrate(DataContext context, ILogger<Program> 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");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using API.Entities;
using API.Entities.History;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class SeriesDontMatchAndBlacklist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "DontMatch",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsBlacklisted",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DontMatch",
table: "Series");
migrationBuilder.DropColumn(
name: "IsBlacklisted",
table: "Series");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class EmailHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EmailHistory",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Sent = table.Column<bool>(type: "INTEGER", nullable: false),
SendDate = table.Column<DateTime>(type: "TEXT", nullable: false),
EmailTemplate = table.Column<string>(type: "TEXT", nullable: true),
Subject = table.Column<string>(type: "TEXT", nullable: true),
Body = table.Column<string>(type: "TEXT", nullable: true),
DeliveryStatus = table.Column<string>(type: "TEXT", nullable: true),
ErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModifiedUtc = table.Column<DateTime>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmailHistory");
}
}
}

View File

@ -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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("DeliveryStatus")
.HasColumnType("TEXT");
b.Property<string>("EmailTemplate")
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("SendDate")
.HasColumnType("TEXT");
b.Property<bool>("Sent")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
@ -1866,12 +1917,18 @@ namespace API.Data.Migrations
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<bool>("DontMatch")
.HasColumnType("INTEGER");
b.Property<string>("FolderPath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<bool>("IsBlacklisted")
.HasColumnType("INTEGER");
b.Property<DateTime>("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")

View File

@ -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<IList<EmailHistoryDto>> 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<IList<EmailHistoryDto>> GetEmailDtos(UserParams userParams)
{
return await _context.EmailHistory
.OrderByDescending(h => h.SendDate)
.ProjectTo<EmailHistoryDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
}

View File

@ -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<ExternalRecommendation>? recommendations);
void Remove(ExternalSeriesMetadata metadata);
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId);
Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId);
Task<bool> NeedsDataRefresh(int seriesId);
Task<SeriesDetailPlusDto?> GetSeriesDetailPlusDto(int seriesId);
Task LinkRecommendationsToSeries(Series series);
Task LinkRecommendationsToSeries(int seriesId);
Task<bool> IsBlacklistedSeries(int seriesId);
Task CreateBlacklistedSeries(int seriesId, bool saveChanges = true);
Task RemoveFromBlacklist(int seriesId);
Task<IList<int>> GetAllSeriesIdsWithoutMetadata(int limit);
Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter);
}
public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepository
@ -107,7 +106,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
.FirstOrDefaultAsync();
}
public async Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId)
public async Task<bool> 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<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId)
public async Task<SeriesDetailPlusDto?> 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);
}
/// <summary>
/// Searches Recommendations without a SeriesId on record and attempts to link based on Series Name/Localized Name
/// </summary>
@ -210,45 +202,12 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
public Task<bool> IsBlacklistedSeries(int seriesId)
{
return _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId);
return _context.Series
.Where(s => s.Id == seriesId)
.Select(s => s.IsBlacklisted)
.FirstOrDefaultAsync();
}
/// <summary>
/// Creates a new instance against SeriesId and Saves to the DB
/// </summary>
/// <param name="seriesId"></param>
/// <param name="saveChanges"></param>
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();
}
}
/// <summary>
/// Removes the Series from Blacklist and Saves to the DB
/// </summary>
/// <param name="seriesId"></param>
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<IList<int>> GetAllSeriesIdsWithoutMetadata(int limit)
{
@ -261,4 +220,14 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
.Take(limit)
.ToListAsync();
}
public async Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter)
{
return await _context.Series
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
.FilterMatchState(filter.MatchStateOption)
.OrderBy(s => s.NormalizedName)
.ProjectTo<ManageMatchSeriesDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
}

View File

@ -29,6 +29,7 @@ public interface IScrobbleRepository
Task<ScrobbleEvent?> GetEvent(int userId, int seriesId, ScrobbleEventType eventType);
Task<IEnumerable<ScrobbleEvent>> GetUserEventsForSeries(int userId, int seriesId);
Task<PagedList<ScrobbleEventDto>> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination);
Task<IList<ScrobbleEvent>> GetAllEventsForSeries(int seriesId);
}
/// <summary>
@ -153,4 +154,10 @@ public class ScrobbleRepository : IScrobbleRepository
return await PagedList<ScrobbleEventDto>.CreateAsync(query, pagination.PageNumber, pagination.PageSize);
}
public async Task<IList<ScrobbleEvent>> GetAllEventsForSeries(int seriesId)
{
return await _context.ScrobbleEvent.Where(e => e.SeriesId == seriesId)
.ToListAsync();
}
}

View File

@ -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<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None);
Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId);
Task<int> GetCountAsync();
Task<Series?> 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<int?>(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.
/// </summary>
/// <remarks>This does not restrict to the user at all. That is handled at the API level.</remarks>
/// <param name="userId"></param>
/// <param name="names"></param>
/// <returns></returns>
public async Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIds(IEnumerable<string> 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<Series?> 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<string>())
.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
}
/// <summary>
/// Returns the Average rating for all users within Kavita instance
/// </summary>

View File

@ -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<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId);
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
}
public class UserRepository : IUserRepository
@ -490,6 +494,43 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IEnumerable<UserTokenInfo>> 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;
}
/// <summary>
/// Returns the first user with a device email matching
/// </summary>
/// <param name="deviceEmail"></param>
/// <returns></returns>
public async Task<AppUser> GetUserByDeviceEmail(string deviceEmail)
{
return await _context.AppUser
.Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail))
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{

View File

@ -32,6 +32,7 @@ public interface IUnitOfWork
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
IEmailHistoryRepository EmailHistoryRepository { get; }
bool Commit();
Task<bool> 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);
}
/// <summary>
@ -100,6 +102,7 @@ public class UnitOfWork : IUnitOfWork
public IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
public IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
public IEmailHistoryRepository EmailHistoryRepository { get; }
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View File

@ -0,0 +1,28 @@
<tr>
<td valign="top" style="text-align: center; padding: 20px 0 10px 20px;">
<h1 style="margin: 0; font-family: 'Montserrat', sans-serif; font-size: 30px; line-height: 36px; font-weight: bold;">Your {{Provider}} Token is Expired!</h1> </td>
</tr>
<tr>
<td valign="top" style="text-align: center; padding: 10px 20px 15px 20px; font-family: sans-serif; font-size: 18px; line-height: 20px;">
<p style="margin: 0;">Kavita will stop syncing with {{Provider}} until you renew your token.</p>
</td>
</tr>
<tr>
<td valign="top" align="center" style="text-align: center; padding: 15px 0px 20px 0px;">
<!-- Button : BEGIN -->
<center>
<table role="presentation" align="center" cellspacing="0" cellpadding="0" border="0" class="center-on-narrow" style="text-align: center;">
<tr>
<td style="border-radius: 50px; background: #153643; text-align: center;" class="button-td">
<a rel="noopener noreferrer" href="{{Link}}" style="background: #153643; border: 15px solid #153643; font-family: 'Montserrat', sans-serif; font-size: 14px; line-height: 1.1; text-align: center; text-decoration: none; display: block; border-radius: 50px; font-weight: bold;" class="button-a">
<span style="color:#ffffff;" class="button-link">&nbsp;&nbsp;&nbsp;&nbsp;RENEW&nbsp;&nbsp;&nbsp;&nbsp;</span> </a>
</td>
</tr>
</table>
</center>
<!-- Button : END -->
<tr>
<td valign="top" style="text-align: center; padding: 10px 20px 15px 20px; font-family: sans-serif; font-size: 12px; line-height: 20px;">
<p style="margin: 0;">If the button above does not work, please find the link here: <a style="color:inherit;margin: 0;width: 100%;word-break: break-all;" rel="noopener noreferrer" href="{{Link}}">{{Link}}</a></p>
</td>
</tr>

View File

@ -0,0 +1,28 @@
<tr>
<td valign="top" style="text-align: center; padding: 20px 0 10px 20px;">
<h1 style="margin: 0; font-family: 'Montserrat', sans-serif; font-size: 30px; line-height: 36px; font-weight: bold;">Your {{Provider}} Token will Expire soon!</h1> </td>
</tr>
<tr>
<td valign="top" style="text-align: center; padding: 10px 20px 15px 20px; font-family: sans-serif; font-size: 18px; line-height: 20px;">
<p style="margin: 0;">Kavita will stop syncing with {{Provider}} until you renew your token.</p>
</td>
</tr>
<tr>
<td valign="top" align="center" style="text-align: center; padding: 15px 0px 20px 0px;">
<!-- Button : BEGIN -->
<center>
<table role="presentation" align="center" cellspacing="0" cellpadding="0" border="0" class="center-on-narrow" style="text-align: center;">
<tr>
<td style="border-radius: 50px; background: #153643; text-align: center;" class="button-td">
<a rel="noopener noreferrer" href="{{Link}}" style="background: #153643; border: 15px solid #153643; font-family: 'Montserrat', sans-serif; font-size: 14px; line-height: 1.1; text-align: center; text-decoration: none; display: block; border-radius: 50px; font-weight: bold;" class="button-a">
<span style="color:#ffffff;" class="button-link">&nbsp;&nbsp;&nbsp;&nbsp;RENEW&nbsp;&nbsp;&nbsp;&nbsp;</span> </a>
</td>
</tr>
</table>
</center>
<!-- Button : END -->
<tr>
<td valign="top" style="text-align: center; padding: 10px 20px 15px 20px; font-family: sans-serif; font-size: 12px; line-height: 20px;">
<p style="margin: 0;">If the button above does not work, please find the link here: <a style="color:inherit;margin: 0;width: 100%;word-break: break-all;" rel="noopener noreferrer" href="{{Link}}">{{Link}}</a></p>
</td>
</tr>

View File

@ -76,6 +76,8 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// </summary>
public string? MalAccessToken { get; set; }
/// <summary>
/// A list of Series the user doesn't want scrobbling for
/// </summary>

View File

@ -0,0 +1,31 @@
using System;
using API.Entities.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace API.Entities;
/// <summary>
/// Records all emails that are sent from Kavita
/// </summary>
[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; }
}

View File

@ -0,0 +1,9 @@
namespace API.Entities.History;
/// <summary>
/// Records history of actions Kavita+ takes
/// </summary>
// public class KavitaPlusHistory
// {
//
// }

View File

@ -1,6 +1,6 @@
using System;
namespace API.Entities;
namespace API.Entities.History;
/// <summary>
/// This will track manual migrations so that I can use simple selects to check if a Manual Migration is needed

View File

@ -23,7 +23,7 @@ public class ExternalSeriesMetadata
/// <summary>
/// Average External Rating. -1 means not set, 0 - 100
/// </summary>
public int AverageExternalRating { get; set; } = 0;
public int AverageExternalRating { get; set; } = -1;
public int AniListId { get; set; }
public long MalId { get; set; }

View File

@ -5,10 +5,12 @@ namespace API.Entities.Metadata;
/// <summary>
/// A blacklist of Series for Kavita+
/// </summary>
[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;
}

View File

@ -28,7 +28,7 @@ public class ScrobbleEvent : IEntityDate
/// </summary>
public string? ReviewBody { get; set; }
public string? ReviewTitle { get; set; }
public required MediaFormat Format { get; set; }
public required PlusMediaFormat Format { get; set; }
/// <summary>
/// Depends on the ScrobbleEvent if filled in
/// </summary>

View File

@ -103,6 +103,17 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
public int MaxHoursToRead { get; set; }
public float AvgHoursToRead { get; set; }
#region KavitaPlus
/// <summary>
/// Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling.
/// </summary>
public bool DontMatch { get; set; }
/// <summary>
/// If the series was unable to match, it will be blacklisted until a manual metadata match overrides it
/// </summary>
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;
}
/// <summary>
/// Is this Series capable of Scrobbling
/// </summary>
/// <remarks>This includes if there is no Match/Manual Match needed, the series is blacklisted, or has a NoMatch</remarks>
/// <returns></returns>
public bool WillScrobble()
{
return !IsBlacklisted && !DontMatch;
}
}

View File

@ -24,8 +24,6 @@ public static class ApplicationServiceExtensions
{
services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly);
//services.AddScoped<DataContext>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IFileService, FileService>();
@ -52,7 +50,6 @@ public static class ApplicationServiceExtensions
services.AddScoped<IStatisticService, StatisticService>();
services.AddScoped<IMediaErrorService, MediaErrorService>();
services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IRecommendationService, RecommendationService>();
services.AddScoped<IStreamService, StreamService>();
services.AddScoped<IScannerService, ScannerService>();
@ -77,6 +74,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<ILicenseService, LicenseService>();
services.AddScoped<IExternalMetadataService, ExternalMetadataService>();
services.AddScoped<ISmartCollectionSyncService, SmartCollectionSyncService>();
services.AddScoped<IWantToReadSyncService, WantToReadSyncService>();
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 =>

View File

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

View File

@ -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<LibraryType> 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<MangaFormat> 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]
};
}
}

View File

@ -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<Series> FilterMatchState(this IQueryable<Series> 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)
};
}
}

View File

@ -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;
}
/// <summary>
/// v0.8.2.3 is within v0.8.2 (v1). Essentially checks if this is a Nightly of a stable release
/// </summary>
/// <param name="v1"></param>
/// <param name="v2"></param>
/// <returns></returns>
public static bool IsWithinStableRelease(this Version v1, Version v2)
{
return v1.Major == v2.Major && v1.Minor != v2.Minor && v1.Build != v2.Build;
}
}

View File

@ -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<ExternalRecommendation, ExternalSeriesDto>();
CreateMap<Series, ManageMatchSeriesDto>()
.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<MangaFile, FileExtensionExportDto>();
CreateMap<EmailHistory, EmailHistoryDto>()
.ForMember(dest => dest.ToUserName, opt => opt.MapFrom(src => src.AppUser.UserName));
CreateMap<Chapter, StandaloneChapterDto>()
.ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.Volume.SeriesId))

View File

@ -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<PlusSeriesDto>
{
_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<int?>(series.Metadata.WebLinks,

View File

@ -0,0 +1,18 @@
using System;
namespace API.Extensions;
public static class DayOfWeekHelper
{
private static readonly Random Rnd = new();
/// <summary>
/// Returns a random DayOfWeek value.
/// </summary>
/// <returns>A randomly selected DayOfWeek.</returns>
public static DayOfWeek Random()
{
var values = Enum.GetValues<DayOfWeek>();
return (DayOfWeek)values.GetValue(Rnd.Next(values.Length))!;
}
}

View File

@ -12,8 +12,10 @@ using Microsoft.EntityFrameworkCore;
namespace API.Helpers;
#nullable enable
public static class GenreHelper
{
public static async Task UpdateChapterGenres(Chapter chapter, IEnumerable<string> genreNames, IUnitOfWork unitOfWork)
{
// Normalize genre names once and store them in a hash set for quick lookups

40
API/Helpers/JwtHelper.cs Normal file
View File

@ -0,0 +1,40 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
namespace API.Helpers;
public static class JwtHelper
{
/// <summary>
/// Extracts the expiration date from a JWT token.
/// </summary>
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;
}
/// <summary>
/// Checks if a JWT token is valid based on its expiry date.
/// </summary>
public static bool IsTokenValid(string jwtToken)
{
if (string.IsNullOrEmpty(jwtToken)) return false;
var expiry = GetTokenExpiry(jwtToken);
return expiry > DateTime.UtcNow;
}
}

Some files were not shown because too many files have changed in this diff Show More