Merged develop in

This commit is contained in:
Joseph Milazzo 2025-04-26 16:17:05 -05:00
commit d12a79892f
1443 changed files with 215765 additions and 44113 deletions

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,9 +12,6 @@
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
</Target>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
@ -53,58 +50,58 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="30.1.0" />
<PackageReference Include="MailKit" Version="4.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1">
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Docnet.Core" Version="2.6.0" />
<PackageReference Include="EasyCaching.InMemory" Version="1.9.2" />
<PackageReference Include="ExCSS" Version="4.2.4" />
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.9" />
<PackageReference Include="Hangfire.InMemory" Version="0.7.0" />
<PackageReference Include="ExCSS" Version="4.3.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Hangfire" Version="1.8.18" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.58" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.9" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<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="2.4.0" />
<PackageReference Include="NetVips.Native" Version="8.15.1" />
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<PackageReference Include="NetVips" Version="3.0.0" />
<PackageReference Include="NetVips.Native" Version="8.16.1" />
<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="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="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.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.36.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.2" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.19.0.84025">
<PackageReference Include="SharpCompress" Version="0.39.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.3.1" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.15" />
<PackageReference Include="System.Drawing.Common" Version="8.0.1" />
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.13" />
<PackageReference Include="System.Drawing.Common" Version="9.0.4" />
<PackageReference Include="VersOne.Epub" Version="3.3.3" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<ItemGroup>
@ -117,6 +114,7 @@
<None Remove="Hangfire-log.db" />
<None Remove="obj\**" />
<None Remove="cache\**" />
<None Remove="cache-long\**" />
<None Remove="backups\**" />
<None Remove="logs\**" />
<None Remove="temp\**" />
@ -190,10 +188,16 @@
</ItemGroup>
<ItemGroup>
<Folder Include="config\cache-long\" />
<Folder Include="config\themes" />
<Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Assets\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Folder Include="Data\ManualMigrations\v0.8.3\" />
<Folder Include="Extensions\KavitaPlus\" />
<None Include="I18N\**" />
</ItemGroup>

View file

@ -1,3 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=covers/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=dtos_005Cperson/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=wwwroot/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -1,32 +1,34 @@
using System.Collections.Generic;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
namespace API.Comparators;
#nullable enable
/// <summary>
/// Sorts chapters based on their Number. Uses natural ordering of doubles.
/// Sorts chapters based on their Number. Uses natural ordering of doubles. Specials always LAST.
/// </summary>
public class ChapterSortComparer : IComparer<double>
public class ChapterSortComparerDefaultLast : IComparer<float>
{
/// <summary>
/// Normal sort for 2 doubles. 0 always comes last
/// Normal sort for 2 doubles. DefaultChapterNumber always comes last
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <returns></returns>
public int Compare(double x, double y)
public int Compare(float x, float y)
{
if (x == 0.0 && y == 0.0) return 0;
if (x.Is(Parser.DefaultChapterNumber) && y.Is(Parser.DefaultChapterNumber)) return 0;
// if x is 0, it comes second
if (x == 0.0) return 1;
if (x.Is(Parser.DefaultChapterNumber)) return 1;
// if y is 0, it comes second
if (y == 0.0) return -1;
if (y.Is(Parser.DefaultChapterNumber)) return -1;
return x.CompareTo(y);
}
public static readonly ChapterSortComparer Default = new ChapterSortComparer();
public static readonly ChapterSortComparerDefaultLast Default = new ChapterSortComparerDefaultLast();
}
/// <summary>
@ -36,33 +38,43 @@ public class ChapterSortComparer : IComparer<double>
/// This is represented by Chapter 0, Chapter 81.
/// </example>
/// </summary>
public class ChapterSortComparerZeroFirst : IComparer<double>
public class ChapterSortComparerDefaultFirst : IComparer<float>
{
public int Compare(double x, double y)
public int Compare(float x, float y)
{
if (x == 0.0 && y == 0.0) return 0;
if (x.Is(Parser.DefaultChapterNumber) && y.Is(Parser.DefaultChapterNumber)) return 0;
// if x is 0, it comes first
if (x == 0.0) return -1;
if (x.Is(Parser.DefaultChapterNumber)) return -1;
// if y is 0, it comes first
if (y == 0.0) return 1;
if (y.Is(Parser.DefaultChapterNumber)) return 1;
return x.CompareTo(y);
}
public static readonly ChapterSortComparerZeroFirst Default = new ChapterSortComparerZeroFirst();
public static readonly ChapterSortComparerDefaultFirst Default = new ChapterSortComparerDefaultFirst();
}
public class SortComparerZeroLast : IComparer<double>
/// <summary>
/// Sorts chapters based on their Number. Uses natural ordering of doubles. Specials always LAST.
/// </summary>
public class ChapterSortComparerSpecialsLast : IComparer<float>
{
public int Compare(double x, double y)
/// <summary>
/// Normal sort for 2 doubles. DefaultSpecialNumber always comes last
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <returns></returns>
public int Compare(float x, float y)
{
if (x == 0.0 && y == 0.0) return 0;
// if x is 0, it comes last
if (x == 0.0) return 1;
// if y is 0, it comes last
if (y == 0.0) return -1;
if (x.Is(Parser.SpecialVolumeNumber) && y.Is(Parser.SpecialVolumeNumber)) return 0;
// if x is 0, it comes second
if (x.Is(Parser.SpecialVolumeNumber)) return 1;
// if y is 0, it comes second
if (y.Is(Parser.SpecialVolumeNumber)) return -1;
return x.CompareTo(y);
}
public static readonly SortComparerZeroLast Default = new SortComparerZeroLast();
public static readonly ChapterSortComparerSpecialsLast Default = new ChapterSortComparerSpecialsLast();
}

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";
@ -19,4 +23,12 @@ public static class EasyCacheProfiles
/// External Series metadata for Kavita+ recommendation
/// </summary>
public const string KavitaPlusExternalSeries = "kavita+externalSeries";
/// <summary>
/// Match Series metadata for Kavita+ metadata download
/// </summary>
public const string KavitaPlusMatchSeries = "kavita+matchSeries";
/// <summary>
/// All Locales on the Server
/// </summary>
public const string LocaleOptions = "locales";
}

View file

@ -40,8 +40,14 @@ public static class PolicyConstants
/// </summary>
/// <remarks>This is used explicitly for Demo Server. Not sure why it would be used in another fashion</remarks>
public const string ReadOnlyRole = "Read Only";
/// <summary>
/// Ability to promote entities (Collections, Reading Lists, etc).
/// </summary>
public const string PromoteRole = "Promote";
public static readonly ImmutableArray<string> ValidRoles =
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole);
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole, PromoteRole);
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@ -15,6 +16,7 @@ using API.Errors;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using AutoMapper;
using Hangfire;
@ -37,6 +39,9 @@ namespace API.Controllers;
/// </summary>
public class AccountController : BaseApiController
{
// Hardcoded to avoid localization multiple enumeration: https://github.com/Kareadita/Kavita/issues/2829
private const string BadCredentialsMessage = "Your credentials are not correct";
private readonly UserManager<AppUser> _userManager;
private readonly SignInManager<AppUser> _signInManager;
private readonly ITokenService _tokenService;
@ -79,6 +84,7 @@ public class AccountController : BaseApiController
{
var user = await _userManager.Users.SingleOrDefaultAsync(x => x.UserName == resetPasswordDto.UserName);
if (user == null) return Ok(); // Don't report BadRequest as that would allow brute forcing to find accounts on system
_logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName);
if (User.IsInRole(PolicyConstants.ReadOnlyRole))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
@ -132,6 +138,12 @@ public class AccountController : BaseApiController
return BadRequest(usernameValidation);
}
// If Email is empty, default to the username
if (string.IsNullOrEmpty(registerDto.Email))
{
registerDto.Email = registerDto.Username;
}
var user = new AppUserBuilder(registerDto.Username, registerDto.Email,
await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build();
@ -204,7 +216,7 @@ public class AccountController : BaseApiController
if (user == null)
{
_logger.LogWarning("Attempted login by {UserName} failed due to unable to find account", loginDto.Username);
return Unauthorized(await _localizationService.Get("en", "bad-credentials"));
return Unauthorized(BadCredentialsMessage);
}
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account"));
@ -225,10 +237,10 @@ public class AccountController : BaseApiController
if (!result.Succeeded)
{
var errorStr = await _localizationService.Translate(user.Id,
result.IsNotAllowed ? "confirm-email" : "bad-credentials");
_logger.LogWarning("{UserName} failed to log in at {Time}: {Issue}", user.UserName, user.LastActive,
errorStr);
string errorStr = result.IsNotAllowed
? await _localizationService.Translate(user.Id, "confirm-email")
: BadCredentialsMessage;
_logger.LogWarning("{UserName} failed to log in at {Time}: {Issue}", user.UserName, user.LastActive, errorStr);
return Unauthorized(errorStr);
}
}
@ -346,10 +358,11 @@ public class AccountController : BaseApiController
/// <param name="dto"></param>
/// <returns>Returns just if the email was sent or server isn't reachable</returns>
[HttpPost("update/email")]
public async Task<ActionResult> UpdateEmail(UpdateEmailDto? dto)
public async Task<ActionResult<InviteUserResponse>> UpdateEmail(UpdateEmailDto? dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole))
return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload"));
@ -358,12 +371,13 @@ public class AccountController : BaseApiController
// Validate this user's password
if (! await _userManager.CheckPasswordAsync(user, dto.Password))
{
_logger.LogCritical("A user tried to change {UserName}'s email, but password didn't validate", user.UserName);
_logger.LogWarning("A user tried to change {UserName}'s email, but password didn't validate", user.UserName);
return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
}
// Validate no other users exist with this email
if (user.Email!.Equals(dto.Email)) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
if (user.Email!.Equals(dto.Email))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
// Check if email is used by another user
var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
@ -380,18 +394,25 @@ public class AccountController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token"));
}
var isValidEmailAddress = _emailService.IsValidEmail(user.Email);
var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email);
var shouldEmailUser = serverSettings.IsEmailSetup() || !isValidEmailAddress;
user.EmailConfirmed = !shouldEmailUser;
user.ConfirmationToken = token;
await _userManager.UpdateAsync(user);
var emailLink = await _emailService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email);
_logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
if (!shouldEmailUser)
{
_logger.LogInformation("Cannot email admin, email not setup or admin email invalid");
return Ok(new InviteUserResponse
{
EmailLink = string.Empty,
EmailSent = false
EmailSent = false,
InvalidEmail = !isValidEmailAddress
});
}
@ -399,10 +420,7 @@ public class AccountController : BaseApiController
// Send a confirmation email
try
{
var emailLink = await _emailService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email);
_logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
if (!_emailService.IsValidEmail(user.Email))
if (!isValidEmailAddress)
{
_logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email);
return Ok(new InviteUserResponse
@ -434,7 +452,8 @@ public class AccountController : BaseApiController
return Ok(new InviteUserResponse
{
EmailLink = string.Empty,
EmailSent = true
EmailSent = true,
InvalidEmail = !isValidEmailAddress
});
}
catch (Exception ex)
@ -452,6 +471,7 @@ public class AccountController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
if (!await _accountService.CanChangeAgeRestriction(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
@ -489,6 +509,7 @@ public class AccountController : BaseApiController
var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (adminUser == null) return Unauthorized();
if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams);
if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user"));
@ -504,6 +525,21 @@ public class AccountController : BaseApiController
_unitOfWork.UserRepository.Update(user);
}
// Check if email is changing for a non-admin user
var isUpdatingAnotherAccount = user.Id != adminUser.Id;
if (isUpdatingAnotherAccount && !string.IsNullOrEmpty(dto.Email) && user.Email != dto.Email)
{
// Validate username change
var errors = await _accountService.ValidateEmail(dto.Email);
if (errors.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "email-taken"));
user.Email = dto.Email;
user.EmailConfirmed = true; // When an admin performs the flow, we assume the email address is able to receive data
await _userManager.UpdateNormalizedEmailAsync(user);
_unitOfWork.UserRepository.Update(user);
}
// Update roles
var existingRoles = await _userManager.GetRolesAsync(user);
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
@ -607,8 +643,7 @@ public class AccountController : BaseApiController
if (adminUser == null) return Unauthorized(await _localizationService.Translate(userId, "permission-denied"));
dto.Email = dto.Email.Trim();
if (string.IsNullOrEmpty(dto.Email))
return BadRequest(await _localizationService.Translate(userId, "invalid-payload"));
if (string.IsNullOrEmpty(dto.Email)) return BadRequest(await _localizationService.Translate(userId, "invalid-payload"));
_logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email);
@ -618,7 +653,7 @@ public class AccountController : BaseApiController
{
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (await _userManager.IsEmailConfirmedAsync(invitedUser!))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser.UserName));
return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser!.UserName));
return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-invited"));
}
@ -768,6 +803,7 @@ public class AccountController : BaseApiController
{
validationErrors.AddRange(await _accountService.ValidateUsername(dto.Username));
}
validationErrors.AddRange(await _accountService.ValidatePassword(user, dto.Password));
if (validationErrors.Any())
@ -839,6 +875,7 @@ public class AccountController : BaseApiController
return BadRequest(await _localizationService.Translate(user.Id, "generic-user-email-update"));
}
user.ConfirmationToken = null;
user.EmailConfirmed = true;
await _unitOfWork.CommitAsync();
@ -856,7 +893,7 @@ public class AccountController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (user == null)
{
return BadRequest(await _localizationService.Get("en", "bad-credentials"));
return BadRequest(BadCredentialsMessage);
}
try
@ -866,7 +903,7 @@ public class AccountController : BaseApiController
if (!result)
{
_logger.LogInformation("Unable to reset password, your email token is not correct: {@Dto}", dto);
return BadRequest(await _localizationService.Translate(user.Id, "bad-credentials"));
return BadRequest(BadCredentialsMessage);
}
var errors = await _accountService.ChangeUserPassword(user, dto.Password);
@ -890,10 +927,7 @@ public class AccountController : BaseApiController
[EnableRateLimiting("Authentication")]
public async Task<ActionResult<string>> ForgotPassword([FromQuery] string email)
{
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (!settings.IsEmailSetup()) return Ok(await _localizationService.Get("en", "email-not-enabled"));
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email);
if (user == null)
{
@ -908,11 +942,7 @@ public class AccountController : BaseApiController
if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed)
return BadRequest(await _localizationService.Translate(user.Id, "confirm-email"));
if (!_emailService.IsValidEmail(user.Email))
{
_logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send. Admin must change it in UI", user.Email);
return Ok(await _localizationService.Translate(user.Id, "invalid-email"));
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var emailLink = await _emailService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email);
@ -921,6 +951,13 @@ public class AccountController : BaseApiController
await _unitOfWork.CommitAsync();
_logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
if (!settings.IsEmailSetup()) return Ok(await _localizationService.Get("en", "email-not-enabled"));
if (!_emailService.IsValidEmail(user.Email))
{
_logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send. Admin must change it in UI or from url above", user.Email);
return Ok(await _localizationService.Translate(user.Id, "invalid-email"));
}
var installId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value;
BackgroundJob.Enqueue(() => _emailService.SendForgotPasswordEmail(new PasswordResetEmailDto()
{
@ -946,12 +983,12 @@ public class AccountController : BaseApiController
public async Task<ActionResult<UserDto>> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (user == null) return BadRequest(await _localizationService.Get("en", "bad-credentials"));
if (user == null) return BadRequest(BadCredentialsMessage);
if (!await ConfirmEmailToken(dto.Token, user))
{
_logger.LogInformation("confirm-migration-email email token is invalid");
return BadRequest(await _localizationService.Translate(user.Id, "bad-credentials"));
return BadRequest(BadCredentialsMessage);
}
await _unitOfWork.CommitAsync();
@ -990,6 +1027,8 @@ public class AccountController : BaseApiController
await _localizationService.Translate(user.Id, "user-migration-needed"));
if (user.EmailConfirmed) return BadRequest(await _localizationService.Translate(user.Id, "user-already-confirmed"));
// TODO: If the target user is read only, we might want to just forgo this
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
user.ConfirmationToken = token;
_unitOfWork.UserRepository.Update(user);

View file

@ -1,4 +1,10 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.ManualMigrations;
using API.DTOs;
using API.DTOs.Progress;
using API.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@ -25,7 +31,7 @@ public class AdminController : BaseApiController
[HttpGet("exists")]
public async Task<ActionResult<bool>> AdminExists()
{
var users = await _userManager.GetUsersInRoleAsync("Admin");
var users = await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
return users.Count > 0;
}
}

View file

@ -50,7 +50,7 @@ public class BookController : BaseApiController
case MangaFormat.Epub:
{
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions);
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions);
bookTitle = book.Title;
break;
}
@ -103,7 +103,7 @@ public class BookController : BaseApiController
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist"));
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions);
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions);
var key = BookService.CoalesceKeyForAnyFile(book, file);
if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing"));

View file

@ -2,11 +2,13 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs.ReadingLists.CBL;
using API.Extensions;
using API.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
namespace API.Controllers;
@ -19,35 +21,40 @@ public class CblController : BaseApiController
{
private readonly IReadingListService _readingListService;
private readonly IDirectoryService _directoryService;
private readonly ILocalizationService _localizationService;
public CblController(IReadingListService readingListService, IDirectoryService directoryService)
public CblController(IReadingListService readingListService, IDirectoryService directoryService, ILocalizationService localizationService)
{
_readingListService = readingListService;
_directoryService = directoryService;
_localizationService = localizationService;
}
/// <summary>
/// The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful.
/// If this returns errors, the cbl will always be rejected by Kavita.
/// </summary>
/// <param name="file">FormBody with parameter name of cbl</param>
/// <param name="cbl">FormBody with parameter name of cbl</param>
/// <param name="useComicVineMatching">Use comic vine matching or not. Defaults to false</param>
/// <returns></returns>
[HttpPost("validate")]
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl([FromForm(Name = "cbl")] IFormFile file)
[SwaggerIgnore]
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl(IFormFile cbl, [FromQuery] bool useComicVineMatching = false)
{
var userId = User.GetUserId();
try
{
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.ValidateCblFile(userId, cbl);
importSummary.FileName = file.FileName;
var cblReadingList = await SaveAndLoadCblFile(cbl);
var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, useComicVineMatching);
importSummary.FileName = cbl.FileName;
return Ok(importSummary);
}
catch (ArgumentNullException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
@ -62,7 +69,7 @@ public class CblController : BaseApiController
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
@ -79,24 +86,29 @@ public class CblController : BaseApiController
/// <summary>
/// Performs the actual import (assuming dryRun = false)
/// </summary>
/// <param name="file">FormBody with parameter name of cbl</param>
/// <param name="cbl">FormBody with parameter name of cbl</param>
/// <param name="dryRun">If true, will only emulate the import but not perform. This should be done to preview what will happen</param>
/// <param name="useComicVineMatching">Use comic vine matching or not. Defaults to false</param>
/// <returns></returns>
[HttpPost("import")]
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl([FromForm(Name = "cbl")] IFormFile file, [FromForm(Name = "dryRun")] bool dryRun = false)
[SwaggerIgnore]
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool useComicVineMatching = false)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
try
{
var userId = User.GetUserId();
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun);
importSummary.FileName = file.FileName;
var cblReadingList = await SaveAndLoadCblFile(cbl);
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, useComicVineMatching);
importSummary.FileName = cbl.FileName;
return Ok(importSummary);
} catch (ArgumentNullException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
@ -111,7 +123,7 @@ public class CblController : BaseApiController
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{

View file

@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Person;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Nager.ArticleNumber;
namespace API.Controllers;
public class ChapterController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
private readonly ILogger<ChapterController> _logger;
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_eventHub = eventHub;
_logger = logger;
}
/// <summary>
/// Gets a single chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet]
public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId)
{
var chapter =
await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId,
ChapterIncludes.People | ChapterIncludes.Files);
return Ok(chapter);
}
/// <summary>
/// Removes a Chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete]
public async Task<ActionResult<bool>> DeleteChapter(int chapterId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter == null)
return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var vol = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId, VolumeIncludes.Chapters);
if (vol == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist"));
// If there is only 1 chapter within the volume, then we need to remove the volume
var needToRemoveVolume = vol.Chapters.Count == 1;
if (needToRemoveVolume)
{
_unitOfWork.VolumeRepository.Remove(vol);
}
else
{
_unitOfWork.ChapterRepository.Remove(chapter);
}
if (!await _unitOfWork.CommitAsync()) return Ok(false);
await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, vol.SeriesId), false);
if (needToRemoveVolume)
{
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(chapter.VolumeId, vol.SeriesId), false);
}
return Ok(true);
}
/// <summary>
/// Deletes multiple chapters and any volumes with no leftover chapters
/// </summary>
/// <param name="seriesId">The ID of the series</param>
/// <param name="dto">The IDs of the chapters to be deleted</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("delete-multiple")]
public async Task<ActionResult<bool>> DeleteMultipleChapters([FromQuery] int seriesId, DeleteChaptersDto dto)
{
try
{
var chapterIds = dto.ChapterIds;
if (chapterIds == null || chapterIds.Count == 0)
{
return BadRequest("ChapterIds required");
}
// Fetch all chapters to be deleted
var chapters = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)).ToList();
// Group chapters by their volume
var volumesToUpdate = chapters.GroupBy(c => c.VolumeId).ToList();
var removedVolumes = new List<int>();
foreach (var volumeGroup in volumesToUpdate)
{
var volumeId = volumeGroup.Key;
var chaptersToDelete = volumeGroup.ToList();
// Fetch the volume
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
if (volume == null)
return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist"));
// Check if all chapters in the volume are being deleted
var isVolumeToBeRemoved = volume.Chapters.Count == chaptersToDelete.Count;
if (isVolumeToBeRemoved)
{
_unitOfWork.VolumeRepository.Remove(volume);
removedVolumes.Add(volume.Id);
}
else
{
// Remove only the specified chapters
_unitOfWork.ChapterRepository.Remove(chaptersToDelete);
}
}
if (!await _unitOfWork.CommitAsync()) return Ok(false);
// Send events for removed chapters
foreach (var chapter in chapters)
{
await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved,
MessageFactory.ChapterRemovedEvent(chapter.Id, seriesId), false);
}
// Send events for removed volumes
foreach (var volumeId in removedVolumes)
{
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved,
MessageFactory.VolumeRemovedEvent(volumeId, seriesId), false);
}
return Ok(true);
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occured while deleting chapters");
return BadRequest(_localizationService.Translate(User.GetUserId(), "generic-error"));
}
}
/// <summary>
/// Update chapter metadata
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateChapterMetadata(UpdateChapterDto dto)
{
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.Id,
ChapterIncludes.People | ChapterIncludes.Genres | ChapterIncludes.Tags);
if (chapter == null)
return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
if (chapter.AgeRating != dto.AgeRating)
{
chapter.AgeRating = dto.AgeRating;
}
dto.Summary ??= string.Empty;
if (chapter.Summary != dto.Summary.Trim())
{
chapter.Summary = dto.Summary.Trim();
}
if (chapter.Language != dto.Language)
{
chapter.Language = dto.Language ?? string.Empty;
}
if (chapter.SortOrder.IsNot(dto.SortOrder))
{
chapter.SortOrder = dto.SortOrder; // TODO: Figure out validation
}
if (chapter.TitleName != dto.TitleName)
{
chapter.TitleName = dto.TitleName;
}
if (chapter.ReleaseDate != dto.ReleaseDate)
{
chapter.ReleaseDate = dto.ReleaseDate;
}
if (!string.IsNullOrEmpty(dto.ISBN) && ArticleNumberHelper.IsValidIsbn10(dto.ISBN) ||
ArticleNumberHelper.IsValidIsbn13(dto.ISBN))
{
chapter.ISBN = dto.ISBN;
}
if (string.IsNullOrEmpty(dto.WebLinks))
{
chapter.WebLinks = string.Empty;
} else
{
chapter.WebLinks = string.Join(',', dto.WebLinks
.Split(',')
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => s.Trim())!
);
}
#region Genres
chapter.Genres ??= [];
await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), _unitOfWork);
#endregion
#region Tags
chapter.Tags ??= [];
await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), _unitOfWork);
#endregion
#region People
chapter.People ??= [];
// Update writers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Writers.Select(p => p.Name).ToList(),
PersonRole.Writer,
_unitOfWork
);
// Update characters
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Characters.Select(p => p.Name).ToList(),
PersonRole.Character,
_unitOfWork
);
// Update pencillers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Pencillers.Select(p => p.Name).ToList(),
PersonRole.Penciller,
_unitOfWork
);
// Update inkers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Inkers.Select(p => p.Name).ToList(),
PersonRole.Inker,
_unitOfWork
);
// Update colorists
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Colorists.Select(p => p.Name).ToList(),
PersonRole.Colorist,
_unitOfWork
);
// Update letterers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Letterers.Select(p => p.Name).ToList(),
PersonRole.Letterer,
_unitOfWork
);
// Update cover artists
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.CoverArtists.Select(p => p.Name).ToList(),
PersonRole.CoverArtist,
_unitOfWork
);
// Update editors
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Editors.Select(p => p.Name).ToList(),
PersonRole.Editor,
_unitOfWork
);
// Update publishers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Publishers.Select(p => p.Name).ToList(),
PersonRole.Publisher,
_unitOfWork
);
// Update translators
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Translators.Select(p => p.Name).ToList(),
PersonRole.Translator,
_unitOfWork
);
// Update imprints
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Imprints.Select(p => p.Name).ToList(),
PersonRole.Imprint,
_unitOfWork
);
// Update teams
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Teams.Select(p => p.Name).ToList(),
PersonRole.Team,
_unitOfWork
);
// Update locations
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Locations.Select(p => p.Name).ToList(),
PersonRole.Location,
_unitOfWork
);
#endregion
#region Locks
chapter.AgeRatingLocked = dto.AgeRatingLocked;
chapter.LanguageLocked = dto.LanguageLocked;
chapter.TitleNameLocked = dto.TitleNameLocked;
chapter.SortOrderLocked = dto.SortOrderLocked;
chapter.GenresLocked = dto.GenresLocked;
chapter.TagsLocked = dto.TagsLocked;
chapter.CharacterLocked = dto.CharacterLocked;
chapter.ColoristLocked = dto.ColoristLocked;
chapter.EditorLocked = dto.EditorLocked;
chapter.InkerLocked = dto.InkerLocked;
chapter.ImprintLocked = dto.ImprintLocked;
chapter.LettererLocked = dto.LettererLocked;
chapter.PencillerLocked = dto.PencillerLocked;
chapter.PublisherLocked = dto.PublisherLocked;
chapter.TranslatorLocked = dto.TranslatorLocked;
chapter.CoverArtistLocked = dto.CoverArtistLocked;
chapter.WriterLocked = dto.WriterLocked;
chapter.SummaryLocked = dto.SummaryLocked;
chapter.ISBNLocked = dto.ISBNLocked;
chapter.ReleaseDateLocked = dto.ReleaseDateLocked;
#endregion
_unitOfWork.ChapterRepository.Update(chapter);
if (!_unitOfWork.HasChanges())
{
return Ok();
}
// TODO: Emit a ChapterMetadataUpdate out
await _unitOfWork.CommitAsync();
return Ok();
}
}

View file

@ -1,15 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.Entities.Metadata;
using API.Entities;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using Hangfire;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
@ -23,61 +30,70 @@ public class CollectionController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly ICollectionTagService _collectionService;
private readonly ILocalizationService _localizationService;
private readonly IExternalMetadataService _externalMetadataService;
private readonly ISmartCollectionSyncService _collectionSyncService;
private readonly ILogger<CollectionController> _logger;
private readonly IEventHub _eventHub;
/// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
ILocalizationService localizationService)
ILocalizationService localizationService, IExternalMetadataService externalMetadataService,
ISmartCollectionSyncService collectionSyncService, ILogger<CollectionController> logger,
IEventHub eventHub)
{
_unitOfWork = unitOfWork;
_collectionService = collectionService;
_localizationService = localizationService;
_externalMetadataService = externalMetadataService;
_collectionSyncService = collectionSyncService;
_logger = logger;
_eventHub = eventHub;
}
/// <summary>
/// Return a list of all collection tags on the server for the logged in user.
/// Returns all Collection tags for a given User
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<ActionResult<IEnumerable<CollectionTagDto>>> GetAllTags()
public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetAllTags(bool ownedOnly = false)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
if (isAdmin)
{
return Ok(await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync());
}
return Ok(await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(user.Id));
return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), !ownedOnly));
}
/// <summary>
/// Searches against the collection tags on the DB and returns matches that meet the search criteria.
/// <remarks>Search strings will be cleaned of certain fields, like %</remarks>
/// Returns a single Collection tag by Id for a given user
/// </summary>
/// <param name="queryString">Search term</param>
/// <param name="collectionId"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("search")]
public async Task<ActionResult<IEnumerable<CollectionTagDto>>> SearchTags(string? queryString)
[HttpGet("single")]
public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetTag(int collectionId)
{
queryString ??= string.Empty;
queryString = queryString.Replace(@"%", string.Empty);
if (queryString.Length == 0) return await GetAllTags();
return Ok(await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId()));
var collections = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), false);
return Ok(collections.FirstOrDefault(c => c.Id == collectionId));
}
/// <summary>
/// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned)
/// </summary>
/// <param name="seriesId"></param>
/// <param name="ownedOnly"></param>
/// <returns></returns>
[HttpGet("all-series")]
public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetCollectionsBySeries(int seriesId, bool ownedOnly = false)
{
return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosBySeriesAsync(User.GetUserId(), seriesId, !ownedOnly));
}
/// <summary>
/// Checks if a collection exists with the name
/// </summary>
/// <param name="name">If empty or null, will return true as that is invalid</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("name-exists")]
public async Task<ActionResult<bool>> DoesNameExists(string name)
{
return Ok(await _collectionService.TagExistsByName(name));
return Ok(await _unitOfWork.CollectionTagRepository.CollectionExists(name, User.GetUserId()));
}
/// <summary>
@ -86,13 +102,19 @@ public class CollectionController : BaseApiController
/// </summary>
/// <param name="updatedTag"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateTag(CollectionTagDto updatedTag)
public async Task<ActionResult> UpdateTag(AppUserCollectionDto updatedTag)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
try
{
if (await _collectionService.UpdateTag(updatedTag)) return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
if (await _collectionService.UpdateTag(updatedTag, User.GetUserId()))
{
await _eventHub.SendMessageAsync(MessageFactory.CollectionUpdated,
MessageFactory.CollectionUpdatedEvent(updatedTag.Id), false);
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
}
}
catch (KavitaException ex)
{
@ -103,18 +125,100 @@ public class CollectionController : BaseApiController
}
/// <summary>
/// Adds a collection tag onto multiple Series. If tag id is 0, this will create a new tag.
/// Promote/UnPromote multiple collections in one go. Will only update the authenticated user's collections and will only work if the user has promotion role
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("promote-multiple")]
public async Task<ActionResult> PromoteMultipleCollections(PromoteCollectionsDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
// This needs to take into account owner as I can select other users cards
var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds);
var userId = User.GetUserId();
if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole))
{
return BadRequest(await _localizationService.Translate(userId, "permission-denied"));
}
foreach (var collection in collections)
{
if (collection.AppUserId != userId) continue;
collection.Promoted = dto.Promoted;
_unitOfWork.CollectionTagRepository.Update(collection);
}
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Delete multiple collections in one go
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete-multiple")]
public async Task<ActionResult> DeleteMultipleCollections(DeleteCollectionsDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
// This needs to take into account owner as I can select other users cards
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
user.Collections = user.Collections.Where(uc => !dto.CollectionIds.Contains(uc.Id)).ToList();
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Adds multiple series to a collection. If tag id is 0, this will create a new tag.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-for-series")]
public async Task<ActionResult> AddToMultipleSeries(CollectionTagBulkAddDto dto)
{
// Create a new tag and save
var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle);
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok();
// Create a new tag and save
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
AppUserCollection? tag;
if (dto.CollectionTagId == 0)
{
tag = new AppUserCollectionBuilder(dto.CollectionTagTitle).Build();
user.Collections.Add(tag);
}
else
{
// Validate tag doesn't exist
tag = user.Collections.FirstOrDefault(t => t.Id == dto.CollectionTagId);
}
if (tag == null)
{
return BadRequest(_localizationService.Translate(User.GetUserId(), "collection-doesnt-exists"));
}
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList(), false);
foreach (var s in series)
{
if (tag.Items.Contains(s)) continue;
tag.Items.Add(s);
}
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync()) return Ok();
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
@ -124,13 +228,14 @@ public class CollectionController : BaseApiController
/// </summary>
/// <param name="updateSeriesForTagDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")]
public async Task<ActionResult> RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
try
{
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata);
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(updateSeriesForTagDto.Tag.Id, CollectionIncludes.Series);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
@ -145,27 +250,89 @@ public class CollectionController : BaseApiController
}
/// <summary>
/// Removes the collection tag from all Series it was attached to
/// Removes the collection tag from the user
/// </summary>
/// <param name="tagId"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete]
public async Task<ActionResult> DeleteTag(int tagId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
try
{
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
if (user.Collections.All(c => c.Id != tagId))
return BadRequest(await _localizationService.Translate(user.Id, "access-denied"));
if (await _collectionService.DeleteTag(tag))
if (await _collectionService.DeleteTag(tagId, user))
{
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted"));
}
}
catch (Exception)
catch (Exception ex)
{
await _unitOfWork.RollbackAsync();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
/// <summary>
/// For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record,
/// fetch their Mal interest stacks (including restacks)
/// </summary>
/// <returns></returns>
[HttpGet("mal-stacks")]
public async Task<ActionResult<IList<MalStackDto>>> GetMalStacksForUser()
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId()));
}
/// <summary>
/// Imports a MAL Stack into Kavita
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("import-stack")]
public async Task<ActionResult> ImportMalStack(MalStackDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
// Validation check to ensure stack doesn't exist already
if (await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, user.Id))
{
return BadRequest(_localizationService.Translate(user.Id, "collection-already-exists"));
}
try
{
// Create new collection
var newCollection = new AppUserCollectionBuilder(dto.Title)
.WithSource(ScrobbleProvider.Mal)
.WithSourceUrl(dto.Url)
.Build();
user.Collections.Add(newCollection);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
// Trigger Stack Refresh for just one stack (not all)
BackgroundJob.Enqueue(() => _collectionSyncService.Sync(newCollection.Id));
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue importing MAL Stack");
}
return BadRequest(_localizationService.Translate(user.Id, "error-import-stack"));
}
}

View file

@ -0,0 +1,63 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Theme;
using API.Entities.Interfaces;
using API.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
[Authorize]
public class ColorScapeController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
public ColorScapeController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
/// <summary>
/// Returns the color scape for a series
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("series")]
public async Task<ActionResult<ColorScapeDto>> GetColorScapeForSeries(int id)
{
var entity = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(id, User.GetUserId());
return GetColorSpaceDto(entity);
}
/// <summary>
/// Returns the color scape for a volume
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("volume")]
public async Task<ActionResult<ColorScapeDto>> GetColorScapeForVolume(int id)
{
var entity = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(id, User.GetUserId());
return GetColorSpaceDto(entity);
}
/// <summary>
/// Returns the color scape for a chapter
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("chapter")]
public async Task<ActionResult<ColorScapeDto>> GetColorScapeForChapter(int id)
{
var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id);
return GetColorSpaceDto(entity);
}
private ActionResult<ColorScapeDto> GetColorSpaceDto(IHasCoverImage entity)
{
if (entity == null) return Ok(ColorScapeDto.Empty);
return Ok(new ColorScapeDto(entity.PrimaryColor, entity.SecondaryColor));
}
}

View file

@ -7,6 +7,7 @@ using API.DTOs.Device;
using API.Extensions;
using API.Services;
using API.SignalR;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Mvc;
@ -24,20 +25,27 @@ public class DeviceController : BaseApiController
private readonly IEmailService _emailService;
private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService;
private readonly IMapper _mapper;
public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService,
IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService)
IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService, IMapper mapper)
{
_unitOfWork = unitOfWork;
_deviceService = deviceService;
_emailService = emailService;
_eventHub = eventHub;
_localizationService = localizationService;
_mapper = mapper;
}
/// <summary>
/// Creates a new Device
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("create")]
public async Task<ActionResult> CreateOrUpdateDevice(CreateDeviceDto dto)
public async Task<ActionResult<DeviceDto>> CreateOrUpdateDevice(CreateDeviceDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
if (user == null) return Unauthorized();
@ -46,20 +54,22 @@ public class DeviceController : BaseApiController
var device = await _deviceService.Create(dto, user);
if (device == null)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-create"));
return Ok(_mapper.Map<DeviceDto>(device));
}
catch (KavitaException ex)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
}
return Ok();
}
/// <summary>
/// Updates an existing Device
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update")]
public async Task<ActionResult> UpdateDevice(UpdateDeviceDto dto)
public async Task<ActionResult<DeviceDto>> UpdateDevice(UpdateDeviceDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
if (user == null) return Unauthorized();
@ -67,7 +77,7 @@ public class DeviceController : BaseApiController
if (device == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-update"));
return Ok();
return Ok(_mapper.Map<DeviceDto>(device));
}
/// <summary>
@ -100,18 +110,18 @@ public class DeviceController : BaseApiController
[HttpPost("send-to")]
public async Task<ActionResult> SendToDevice(SendToDeviceDto dto)
{
if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
var userId = User.GetUserId();
if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(userId, "greater-0", "ChapterIds"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId"));
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
if (!isEmailSetup)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email"));
// // Validate that the device belongs to the user
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Devices);
if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-unallowed"));
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Devices);
if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(userId, "send-to-unallowed"));
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"started"), userId);
@ -135,26 +145,30 @@ public class DeviceController : BaseApiController
}
/// <summary>
/// Attempts to send a whole series to a device.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("send-series-to")]
public async Task<ActionResult> SendSeriesToDevice(SendSeriesToDeviceDto dto)
{
if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
var userId = User.GetUserId();
if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "SeriesId"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId"));
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
if (!isEmailSetup)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email"));
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"started"), userId);
var series =
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
SeriesIncludes.Volumes | SeriesIncludes.Chapters);
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist"));
if (series == null) return BadRequest(await _localizationService.Translate(userId, "series-doesnt-exist"));
var chapterIds = series.Volumes.SelectMany(v => v.Chapters.Select(c => c.Id)).ToList();
try
{
@ -163,16 +177,16 @@ public class DeviceController : BaseApiController
}
catch (KavitaException ex)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
return BadRequest(await _localizationService.Translate(userId, ex.Message));
}
finally
{
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"ended"), userId);
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to"));
return BadRequest(await _localizationService.Translate(userId, "generic-send-to"));
}
}

View file

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using API.Data;
using API.DTOs.Downloads;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.SignalR;
@ -140,7 +141,7 @@ public class DownloadController : BaseApiController
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId);
try
{
return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series!.Name} - Chapter {chapter.Number}.zip");
return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series!.Name} - Chapter {chapter.GetNumberTitle()}.zip");
}
catch (KavitaException ex)
{
@ -157,7 +158,8 @@ public class DownloadController : BaseApiController
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(username,
filename, $"Downloading {filename}", 0F, "started"));
if (files.Count == 1)
if (files.Count == 1 && files.First().Format != MangaFormat.Image)
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(username,
@ -166,15 +168,17 @@ public class DownloadController : BaseApiController
}
var filePath = _archiveService.CreateZipFromFoldersForDownload(files.Select(c => c.FilePath).ToList(), tempFolder, ProgressCallback);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(username,
filename, "Download Complete", 1F, "ended"));
return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true);
async Task ProgressCallback(Tuple<string, float> progressInfo)
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(username, filename, $"Extracting {Path.GetFileNameWithoutExtension(progressInfo.Item1)}",
MessageFactory.DownloadProgressEvent(username, filename, $"Processing {Path.GetFileNameWithoutExtension(progressInfo.Item1)}",
Math.Clamp(progressInfo.Item2, 0F, 1F)));
}
}
@ -192,8 +196,10 @@ public class DownloadController : BaseApiController
public async Task<ActionResult> DownloadSeries(int seriesId)
{
if (!await HasDownloadPermission()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
if (series == null) return BadRequest("Invalid Series");
var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
try
{

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,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Dashboard;
@ -9,7 +10,9 @@ using API.DTOs.Filtering.v2;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
@ -21,10 +24,17 @@ namespace API.Controllers;
public class FilterController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly IStreamService _streamService;
private readonly ILogger<FilterController> _logger;
public FilterController(IUnitOfWork unitOfWork)
public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IStreamService streamService,
ILogger<FilterController> logger)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_streamService = streamService;
_logger = logger;
}
/// <summary>
@ -37,6 +47,7 @@ public class FilterController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.SmartFilters);
if (user == null) return Unauthorized();
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name must be set");
if (Seed.DefaultStreams.Any(s => s.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase)))
@ -78,6 +89,8 @@ public class FilterController : BaseApiController
[HttpDelete]
public async Task<ActionResult> DeleteFilter(int filterId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId);
if (filter == null) return Ok();
// This needs to delete any dashboard filters that have it too
@ -113,4 +126,57 @@ public class FilterController : BaseApiController
{
return Ok(SmartFilterHelper.Decode(dto.EncodedFilter));
}
/// <summary>
/// Rename a Smart Filter given the filterId and new name
/// </summary>
/// <param name="filterId"></param>
/// <param name="name"></param>
/// <returns></returns>
[HttpPost("rename")]
public async Task<ActionResult> RenameFilter([FromQuery] int filterId, [FromQuery] string name)
{
try
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(),
AppUserIncludes.SmartFilters);
if (user == null) return Unauthorized();
name = name.Trim();
if (User.IsInRole(PolicyConstants.ReadOnlyRole))
{
return BadRequest(await _localizationService.Translate(user.Id, "permission-denied"));
}
if (string.IsNullOrWhiteSpace(name))
{
return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-name-required"));
}
if (Seed.DefaultStreams.Any(s => s.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)))
{
return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-system-name"));
}
var filter = user.SmartFilters.FirstOrDefault(f => f.Id == filterId);
if (filter == null)
{
return BadRequest(await _localizationService.Translate(user.Id, "filter-not-found"));
}
filter.Name = name;
_unitOfWork.AppUserSmartFilterRepository.Update(filter);
await _unitOfWork.CommitAsync();
await _streamService.RenameSmartFilterStreams(filter);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when renaming smart filter: {FilterId}", filterId);
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
}
}

View file

@ -7,6 +7,7 @@ using API.Data;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Tasks.Metadata;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MimeTypes;
@ -25,15 +26,20 @@ public class ImageController : BaseApiController
private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService;
private readonly ILocalizationService _localizationService;
private readonly IReadingListService _readingListService;
private readonly ICoverDbService _coverDbService;
/// <inheritdoc />
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService,
IImageService imageService, ILocalizationService localizationService)
IImageService imageService, ILocalizationService localizationService,
IReadingListService readingListService, ICoverDbService coverDbService)
{
_unitOfWork = unitOfWork;
_directoryService = directoryService;
_imageService = imageService;
_localizationService = localizationService;
_readingListService = readingListService;
_coverDbService = coverDbService;
}
/// <summary>
@ -60,7 +66,7 @@ public class ImageController : BaseApiController
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpGet("library-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"libraryId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["libraryId", "apiKey"])]
public async Task<ActionResult> GetLibraryCoverImage(int libraryId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -78,7 +84,7 @@ public class ImageController : BaseApiController
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("volume-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"volumeId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["volumeId", "apiKey"])]
public async Task<ActionResult> GetVolumeCoverImage(int volumeId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -95,7 +101,7 @@ public class ImageController : BaseApiController
/// </summary>
/// <param name="seriesId">Id of Series</param>
/// <returns></returns>
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"seriesId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["seriesId", "apiKey"])]
[HttpGet("series-cover")]
public async Task<ActionResult> GetSeriesCoverImage(int seriesId, string apiKey)
{
@ -111,21 +117,23 @@ public class ImageController : BaseApiController
}
/// <summary>
/// Returns cover image for Collection Tag
/// Returns cover image for Collection
/// </summary>
/// <param name="collectionTagId"></param>
/// <returns></returns>
[HttpGet("collection-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"collectionTagId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["collectionTagId", "apiKey"])]
public async Task<ActionResult> GetCollectionCoverImage(int collectionTagId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
{
var destFile = await GenerateCollectionCoverImage(collectionTagId);
if (string.IsNullOrEmpty(destFile)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image"));
return PhysicalFile(destFile, MimeTypeMap.GetMimeType(_directoryService.FileSystem.Path.GetExtension(destFile)),
_directoryService.FileSystem.Path.GetFileName(destFile));
}
@ -140,15 +148,17 @@ public class ImageController : BaseApiController
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("readinglist-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"readingListId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["readingListId", "apiKey"])]
public async Task<ActionResult> GetReadingListCoverImage(int readingListId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
{
var destFile = await GenerateReadingListCoverImage(readingListId);
var destFile = await _readingListService.GenerateReadingListCoverImage(readingListId);
if (string.IsNullOrEmpty(destFile)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image"));
return PhysicalFile(destFile, MimeTypeMap.GetMimeType(_directoryService.FileSystem.Path.GetExtension(destFile)), _directoryService.FileSystem.Path.GetFileName(destFile));
}
@ -157,22 +167,6 @@ public class ImageController : BaseApiController
return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path));
}
private async Task<string> GenerateReadingListCoverImage(int readingListId)
{
var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId);
var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory,
ImageService.GetReadingListFormat(readingListId));
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
destFile += settings.EncodeMediaAs.GetExtension();
if (_directoryService.FileSystem.File.Exists(destFile)) return destFile;
ImageService.CreateMergedImage(
covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(),
settings.CoverImageSize,
destFile);
return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile;
}
private async Task<string> GenerateCollectionCoverImage(int collectionId)
{
var covers = await _unitOfWork.CollectionTagRepository.GetRandomCoverImagesAsync(collectionId);
@ -180,11 +174,13 @@ public class ImageController : BaseApiController
ImageService.GetCollectionTagFormat(collectionId));
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
destFile += settings.EncodeMediaAs.GetExtension();
if (_directoryService.FileSystem.File.Exists(destFile)) return destFile;
ImageService.CreateMergedImage(
covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(),
settings.CoverImageSize,
destFile);
// TODO: Refactor this so that collections have a dedicated cover image so we can calculate primary/secondary colors
return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile;
}
@ -197,7 +193,8 @@ public class ImageController : BaseApiController
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
/// <returns></returns>
[HttpGet("bookmark")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey"
])]
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -219,7 +216,7 @@ public class ImageController : BaseApiController
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpGet("web-link")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = new []{"url", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["url", "apiKey"])]
public async Task<ActionResult> GetWebLinkImage(string url, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -236,7 +233,47 @@ public class ImageController : BaseApiController
try
{
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory,
await _imageService.DownloadFaviconAsync(url, encodeFormat));
await _coverDbService.DownloadFaviconAsync(url, encodeFormat));
}
catch (Exception)
{
return BadRequest(await _localizationService.Translate(userId, "generic-favicon"));
}
}
var file = new FileInfo(domainFilePath);
var format = Path.GetExtension(file.FullName);
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
}
/// <summary>
/// Returns the image associated with a publisher
/// </summary>
/// <param name="publisherName"></param>
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpGet("publisher")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["publisherName", "apiKey"])]
public async Task<ActionResult> GetPublisherImage(string publisherName, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
if (string.IsNullOrEmpty(publisherName)) return BadRequest(await _localizationService.Translate(userId, "must-be-defined", "publisherName"));
if (publisherName.Contains("..")) return BadRequest();
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
// Check if the domain exists
var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory, ImageService.GetPublisherFormat(publisherName, encodeFormat));
if (!_directoryService.FileSystem.File.Exists(domainFilePath))
{
// We need to request the favicon and save it
try
{
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory,
await _coverDbService.DownloadPublisherImageAsync(publisherName, encodeFormat));
}
catch (Exception)
{
@ -250,6 +287,43 @@ public class ImageController : BaseApiController
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
}
/// <summary>
/// Returns cover image for Person
/// </summary>
/// <param name="personId"></param>
/// <returns></returns>
[HttpGet("person-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["personId", "apiKey"])]
public async Task<ActionResult> GetPersonCoverImage(int personId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.PersonRepository.GetCoverImageAsync(personId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image"));
var format = _directoryService.FileSystem.Path.GetExtension(path);
return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path));
}
/// <summary>
/// Returns cover image for Person
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
[HttpGet("person-cover-by-name")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["personId", "apiKey"])]
public async Task<ActionResult> GetPersonCoverImageByName(string name, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.PersonRepository.GetCoverImageByNameAsync(name));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image"));
var format = _directoryService.FileSystem.Path.GetExtension(path);
return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path));
}
/// <summary>
/// Returns a temp coverupload image
/// </summary>
@ -257,7 +331,7 @@ public class ImageController : BaseApiController
/// <returns></returns>
[Authorize(Policy="RequireAdminRole")]
[HttpGet("cover-upload")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"filename", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["filename", "apiKey"])]
public async Task<ActionResult> GetCoverUploadImage(string filename, string apiKey)
{
if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest();

View file

@ -20,9 +20,10 @@ using API.Services.Tasks.Scanner;
using API.SignalR;
using AutoMapper;
using EasyCaching.Core;
using Hangfire;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration.UserSecrets;
using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler;
@ -79,10 +80,10 @@ public class LibraryController : BaseApiController
.WithFolders(dto.Folders.Select(x => new FolderPath {Path = x}).Distinct().ToList())
.WithFolderWatching(dto.FolderWatching)
.WithIncludeInDashboard(dto.IncludeInDashboard)
.WithIncludeInRecommended(dto.IncludeInRecommended)
.WithManageCollections(dto.ManageCollections)
.WithManageReadingLists(dto.ManageReadingLists)
.WIthAllowScrobbling(dto.AllowScrobbling)
.WithAllowScrobbling(dto.AllowScrobbling)
.WithAllowMetadataMatching(dto.AllowMetadataMatching)
.Build();
library.LibraryFileTypes = dto.FileGroupTypes
@ -134,13 +135,19 @@ public class LibraryController : BaseApiController
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
if (library.FolderWatching)
{
await _libraryWatcher.RestartWatching();
}
BackgroundJob.Enqueue(() => _taskScheduler.ScanLibrary(library.Id, false));
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
MessageFactory.SideNavUpdateEvent(User.GetUserId()), false);
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
return Ok();
}
@ -167,11 +174,35 @@ public class LibraryController : BaseApiController
return Ok(_directoryService.ListDirectory(path));
}
/// <summary>
/// Return a specific library
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet]
public async Task<ActionResult<LibraryDto?>> GetLibrary(int libraryId)
{
var username = User.GetUsername();
if (string.IsNullOrEmpty(username)) return Unauthorized();
var cacheKey = CacheKey + username;
var result = await _libraryCacheProvider.GetAsync<IEnumerable<LibraryDto>>(cacheKey);
if (result.HasValue)
{
return Ok(result.Value.FirstOrDefault(l => l.Id == libraryId));
}
var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username).ToList();
await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
return Ok(ret.Find(l => l.Id == libraryId));
}
/// <summary>
/// Return all libraries in the Server
/// </summary>
/// <returns></returns>
[HttpGet]
[HttpGet("libraries")]
public async Task<ActionResult<IEnumerable<LibraryDto>>> GetLibraries()
{
var username = User.GetUsername();
@ -183,7 +214,6 @@ public class LibraryController : BaseApiController
var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username);
await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
_logger.LogDebug("Caching libraries for {Key}", cacheKey);
return Ok(ret);
}
@ -268,7 +298,23 @@ public class LibraryController : BaseApiController
public async Task<ActionResult> Scan(int libraryId, bool force = false)
{
if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId"));
_taskScheduler.ScanLibrary(libraryId, force);
await _taskScheduler.ScanLibrary(libraryId, force);
return Ok();
}
/// <summary>
/// Enqueues a bunch of library scans
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("scan-multiple")]
public async Task<ActionResult> ScanMultiple(BulkActionDto dto)
{
foreach (var libraryId in dto.Ids)
{
await _taskScheduler.ScanLibrary(libraryId, dto.Force ?? false);
}
return Ok();
}
@ -287,17 +333,63 @@ public class LibraryController : BaseApiController
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("refresh-metadata")]
public ActionResult RefreshMetadata(int libraryId, bool force = true)
public ActionResult RefreshMetadata(int libraryId, bool force = true, bool forceColorscape = true)
{
_taskScheduler.RefreshMetadata(libraryId, force);
_taskScheduler.RefreshMetadata(libraryId, force, forceColorscape);
return Ok();
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("analyze")]
public ActionResult Analyze(int libraryId)
[HttpPost("refresh-metadata-multiple")]
public ActionResult RefreshMetadataMultiple(BulkActionDto dto, bool forceColorscape = true)
{
_taskScheduler.AnalyzeFilesForLibrary(libraryId, true);
foreach (var libraryId in dto.Ids)
{
_taskScheduler.RefreshMetadata(libraryId, dto.Force ?? false, forceColorscape);
}
return Ok();
}
/// <summary>
/// Copy the library settings (adv tab + optional type) to a set of other libraries.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("copy-settings-from")]
public async Task<ActionResult> CopySettingsFromLibraryToLibraries(CopySettingsFromLibraryDto dto)
{
var sourceLibrary = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.SourceLibraryId, LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes);
if (sourceLibrary == null) return BadRequest("SourceLibraryId must exist");
var libraries = await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.TargetLibraryIds, LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes | LibraryIncludes.Folders);
foreach (var targetLibrary in libraries)
{
UpdateLibrarySettings(new UpdateLibraryDto()
{
Folders = targetLibrary.Folders.Select(s => s.Path),
Name = targetLibrary.Name,
Id = targetLibrary.Id,
Type = sourceLibrary.Type,
AllowScrobbling = sourceLibrary.AllowScrobbling,
ExcludePatterns = sourceLibrary.LibraryExcludePatterns.Select(p => p.Pattern).ToList(),
FolderWatching = sourceLibrary.FolderWatching,
ManageCollections = sourceLibrary.ManageCollections,
FileGroupTypes = sourceLibrary.LibraryFileTypes.Select(t => t.FileTypeGroup).ToList(),
IncludeInDashboard = sourceLibrary.IncludeInDashboard,
IncludeInSearch = sourceLibrary.IncludeInSearch,
ManageReadingLists = sourceLibrary.ManageReadingLists
}, targetLibrary, dto.IncludeType);
}
await _unitOfWork.CommitAsync();
if (sourceLibrary.FolderWatching)
{
BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching());
}
return Ok();
}
@ -327,20 +419,65 @@ public class LibraryController : BaseApiController
.Distinct()
.Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath);
var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder,
new List<string>() {dto.FolderPath});
var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]);
_taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath);
return Ok();
}
/// <summary>
/// Deletes the library and all series within it.
/// </summary>
/// <remarks>This does not touch any files</remarks>
/// <param name="libraryId"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete("delete")]
public async Task<ActionResult<bool>> DeleteLibrary(int libraryId)
{
_logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, User.GetUsername());
try
{
return Ok(await DeleteLibrary(libraryId, User.GetUserId()));
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Deletes multiple libraries and all series within it.
/// </summary>
/// <remarks>This does not touch any files</remarks>
/// <param name="libraryIds"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete("delete-multiple")]
public async Task<ActionResult<bool>> DeleteMultipleLibraries([FromQuery] List<int> libraryIds)
{
var username = User.GetUsername();
_logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, username);
_logger.LogInformation("Libraries {LibraryIds} are being deleted by {UserName}", libraryIds, username);
foreach (var libraryId in libraryIds)
{
try
{
await DeleteLibrary(libraryId, User.GetUserId());
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
return Ok();
}
private async Task<bool> DeleteLibrary(int libraryId, int userId)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId);
var seriesIds = series.Select(x => x.Id).ToArray();
var chapterIds =
@ -351,16 +488,19 @@ public class LibraryController : BaseApiController
if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId))
{
_logger.LogInformation("User is attempting to delete a library while a scan is in progress");
return BadRequest(await _localizationService.Translate(User.GetUserId(), "delete-library-while-scan"));
throw new KavitaException(await _localizationService.Translate(userId, "delete-library-while-scan"));
}
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist"));
if (library == null)
{
throw new KavitaException(await _localizationService.Translate(userId, "library-doesnt-exist"));
}
// Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library
// Aka SeriesRelation has an invalid foreign key
foreach (var s in await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id,
SeriesIncludes.Related))
foreach (var s in await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id, SeriesIncludes.Related))
{
s.Relations = new List<SeriesRelation>();
_unitOfWork.SeriesRepository.Update(s);
@ -377,7 +517,7 @@ public class LibraryController : BaseApiController
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
MessageFactory.SideNavUpdateEvent(User.GetUserId()), false);
MessageFactory.SideNavUpdateEvent(userId), false);
if (chapterIds.Any())
{
@ -386,7 +526,7 @@ public class LibraryController : BaseApiController
_taskScheduler.CleanupChapters(chapterIds);
}
await _libraryWatcher.RestartWatching();
BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching());
foreach (var seriesId in seriesIds)
{
@ -396,13 +536,13 @@ public class LibraryController : BaseApiController
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false);
return Ok(true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "There was a critical issue. Please try again");
await _unitOfWork.RollbackAsync();
return Ok(false);
return false;
}
}
@ -444,14 +584,46 @@ public class LibraryController : BaseApiController
var typeUpdate = library.Type != dto.Type;
var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching;
library.Type = dto.Type;
UpdateLibrarySettings(dto, library);
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update"));
if (folderWatchingUpdate || originalFoldersCount != dto.Folders.Count() || typeUpdate)
{
BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching());
}
if (originalFoldersCount != dto.Folders.Count() || typeUpdate)
{
await _taskScheduler.ScanLibrary(library.Id);
}
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "update"), false);
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
MessageFactory.SideNavUpdateEvent(userId), false);
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
return Ok();
}
private void UpdateLibrarySettings(UpdateLibraryDto dto, Library library, bool updateType = true)
{
if (updateType)
{
library.Type = dto.Type;
}
library.FolderWatching = dto.FolderWatching;
library.IncludeInDashboard = dto.IncludeInDashboard;
library.IncludeInRecommended = dto.IncludeInRecommended;
library.IncludeInSearch = dto.IncludeInSearch;
library.ManageCollections = dto.ManageCollections;
library.ManageReadingLists = dto.ManageReadingLists;
library.AllowScrobbling = dto.AllowScrobbling;
library.AllowMetadataMatching = dto.AllowMetadataMatching;
library.LibraryFileTypes = dto.FileGroupTypes
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})
.Distinct()
@ -463,7 +635,7 @@ public class LibraryController : BaseApiController
.ToList();
// Override Scrobbling for Comic libraries since there are no providers to scrobble to
if (library.Type == LibraryType.Comic)
if (library.Type is LibraryType.Comic or LibraryType.ComicVine)
{
_logger.LogInformation("Overrode Library {Name} to disable scrobbling since there are no providers for Comics", dto.Name.Replace(Environment.NewLine, string.Empty));
library.AllowScrobbling = false;
@ -471,28 +643,6 @@ public class LibraryController : BaseApiController
_unitOfWork.LibraryRepository.Update(library);
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update"));
if (originalFoldersCount != dto.Folders.Count() || typeUpdate)
{
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
}
if (folderWatchingUpdate)
{
await _libraryWatcher.RestartWatching();
}
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "update"), false);
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
MessageFactory.SideNavUpdateEvent(userId), false);
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
return Ok();
}
/// <summary>

View file

@ -2,14 +2,17 @@
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.License;
using API.DTOs.KavitaPlus.License;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using EasyCaching.Core;
using Hangfire;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler;
namespace API.Controllers;
@ -20,7 +23,8 @@ public class LicenseController(
ILogger<LicenseController> logger,
ILicenseService licenseService,
ILocalizationService localizationService,
ITaskScheduler taskScheduler)
ITaskScheduler taskScheduler,
IEasyCachingProviderFactory cachingProviderFactory)
: BaseApiController
{
/// <summary>
@ -31,13 +35,22 @@ public class LicenseController(
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
public async Task<ActionResult<bool>> HasValidLicense(bool forceCheck = false)
{
var result = await licenseService.HasActiveLicense(forceCheck);
await taskScheduler.ScheduleKavitaPlusTasks();
var licenseInfoProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
var cacheValue = await licenseInfoProvider.GetAsync<bool>(LicenseService.CacheKey);
if (result && !cacheValue.IsNull && !cacheValue.Value)
{
await taskScheduler.ScheduleKavitaPlusTasks();
}
return Ok(result);
}
/// <summary>
/// Has any license
/// Has any license registered with the instance. Does not check Kavita+ API
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
@ -49,6 +62,30 @@ 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)
{
try
{
return Ok(await licenseService.GetLicenseInfo(forceCheck));
}
catch (Exception)
{
return Ok(null);
}
}
/// <summary>
/// Remove the Kavita+ License on the Server
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpDelete]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
@ -59,10 +96,13 @@ public class LicenseController(
setting.Value = null;
unitOfWork.SettingsRepository.Update(setting);
await unitOfWork.CommitAsync();
await taskScheduler.ScheduleKavitaPlusTasks();
TaskScheduler.RemoveKavitaPlusTasks();
return Ok();
}
[Authorize("RequireAdminRole")]
[HttpPost("reset")]
public async Task<ActionResult> ResetLicense(UpdateLicenseDto dto)

View file

@ -2,9 +2,16 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
using API.DTOs.Filtering;
using API.Services;
using EasyCaching.Core;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
namespace API.Controllers;
@ -13,38 +20,34 @@ namespace API.Controllers;
public class LocaleController : BaseApiController
{
private readonly ILocalizationService _localizationService;
private readonly IEasyCachingProvider _localeCacheProvider;
public LocaleController(ILocalizationService localizationService)
private static readonly string CacheKey = "locales_" + BuildInfo.Version;
public LocaleController(ILocalizationService localizationService, IEasyCachingProviderFactory cachingProviderFactory)
{
_localizationService = localizationService;
_localeCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.LocaleOptions);
}
/// <summary>
/// Returns all applicable locales on the server
/// </summary>
/// <remarks>This can be cached as it will not change per version.</remarks>
/// <returns></returns>
[AllowAnonymous]
[HttpGet]
public ActionResult<IEnumerable<string>> GetAllLocales()
public async Task<ActionResult<IEnumerable<KavitaLocale>>> GetAllLocales()
{
var languages = _localizationService.GetLocales().Select(c =>
{
try
{
var cult = new CultureInfo(c);
return new LanguageDto()
{
Title = cult.DisplayName,
IsoCode = cult.IetfLanguageTag
};
}
catch (Exception ex)
{
// Some OS' don't have all culture codes supported like PT_BR, thus we need to default
return new LanguageDto()
{
Title = c,
IsoCode = c
};
}
})
.Where(l => !string.IsNullOrEmpty(l.IsoCode))
.OrderBy(d => d.Title);
return Ok(languages);
var result = await _localeCacheProvider.GetAsync<IEnumerable<KavitaLocale>>(CacheKey);
if (result.HasValue)
{
return Ok(result.Value);
}
var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f);
await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(1));
return Ok(ret);
}
}

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

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Metadata;
@ -12,6 +13,7 @@ using API.DTOs.Recommendation;
using API.DTOs.SeriesDetail;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Plus;
using Kavita.Common.Extensions;
@ -31,18 +33,17 @@ 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 = new []{"libraryIds"})]
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds)
[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();
if (ids is {Count: > 0})
{
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
}
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(int.Parse)
.ToList();
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId()));
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context));
}
/// <summary>
@ -71,9 +72,9 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids is {Count: > 0})
{
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids));
}
return Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId()));
}
/// <summary>
@ -88,9 +89,9 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids is {Count: > 0})
{
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId(), ids));
}
return Ok(await unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId()));
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId()));
}
/// <summary>
@ -122,7 +123,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
/// <param name="libraryIds">String separated libraryIds or null for all publication status</param>
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
/// <returns></returns>
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])]
[HttpGet("publication-status")]
public ActionResult<IList<AgeRatingDto>> GetAllPublicationStatus(string? libraryIds)
{
@ -146,7 +147,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
/// <returns></returns>
[HttpGet("languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new []{"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])]
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
{
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
@ -169,20 +170,21 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
}).Where(l => !string.IsNullOrEmpty(l.IsoCode));
}
/// <summary>
/// Returns summary for the chapter
/// Given a language code returns the display name
/// </summary>
/// <param name="chapterId"></param>
/// <param name="code"></param>
/// <returns></returns>
[HttpGet("chapter-summary")]
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
[HttpGet("language-title")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["code"])]
public ActionResult<string?> GetLanguageTitle(string code)
{
// TODO: This doesn't seem used anywhere
if (chapterId <= 0) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
return Ok(chapter.Summary);
if (string.IsNullOrEmpty(code)) return BadRequest("Code must be provided");
return CultureInfo.GetCultures(CultureTypes.AllCultures)
.Where(l => code.Equals(l.IetfLanguageTag))
.Select(c => c.DisplayName)
.FirstOrDefault();
}
/// <summary>
@ -191,12 +193,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
@ -224,7 +226,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!;
userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList()));
userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(ret.Reviews.ToList()));
ret.Reviews = userReviews;
if (!isAdmin && ret.Recommendations != null && user != null)

View file

@ -4,24 +4,30 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Serialization;
using API.Comparators;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.OPDS;
using API.DTOs.Progress;
using API.DTOs.Search;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MimeTypes;
namespace API.Controllers;
@ -31,6 +37,7 @@ namespace API.Controllers;
[AllowAnonymous]
public class OpdsController : BaseApiController
{
private readonly ILogger<OpdsController> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IDownloadService _downloadService;
private readonly IDirectoryService _directoryService;
@ -39,6 +46,7 @@ public class OpdsController : BaseApiController
private readonly ISeriesService _seriesService;
private readonly IAccountService _accountService;
private readonly ILocalizationService _localizationService;
private readonly IMapper _mapper;
private readonly XmlSerializer _xmlSerializer;
@ -69,13 +77,14 @@ public class OpdsController : BaseApiController
};
private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto();
private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default;
private const int PageSize = 20;
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
IDirectoryService directoryService, ICacheService cacheService,
IReaderService readerService, ISeriesService seriesService,
IAccountService accountService, ILocalizationService localizationService)
IAccountService accountService, ILocalizationService localizationService,
IMapper mapper, ILogger<OpdsController> logger)
{
_unitOfWork = unitOfWork;
_downloadService = downloadService;
@ -85,6 +94,8 @@ public class OpdsController : BaseApiController
_seriesService = seriesService;
_accountService = accountService;
_localizationService = localizationService;
_mapper = mapper;
_logger = logger;
_xmlSerializer = new XmlSerializer(typeof(Feed));
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
@ -183,10 +194,11 @@ public class OpdsController : BaseApiController
{
Text = stream.Name
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{stream.SmartFilterId}/"),
}
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/smart-filters/{stream.SmartFilterId}/")
]
});
break;
}
@ -286,7 +298,7 @@ public class OpdsController : BaseApiController
{
var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value;
var prefix = "/api/opds/";
if (!Configuration.DefaultBaseUrl.Equals(baseUrl))
if (!Configuration.DefaultBaseUrl.Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase))
{
// We need to update the Prefix to account for baseUrl
prefix = baseUrl + "api/opds/";
@ -299,7 +311,7 @@ public class OpdsController : BaseApiController
/// Returns the Series matching this smart filter. If FromDashboard, will only return 20 records.
/// </summary>
/// <returns></returns>
[HttpGet("{apiKey}/smart-filter/{filterId}")]
[HttpGet("{apiKey}/smart-filters/{filterId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 0)
{
@ -311,8 +323,8 @@ public class OpdsController : BaseApiController
var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId);
if (filter == null) return BadRequest(_localizationService.Translate(userId, "smart-filter-doesnt-exist"));
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilter-" + filter.Id), $"{prefix}{apiKey}/smart-filter/{filter.Id}/", apiKey, prefix);
SetFeedId(feed, "smartFilter-" + filter.Id);
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters-" + filter.Id), $"{apiKey}/smart-filters/{filter.Id}/", apiKey, prefix);
SetFeedId(feed, "smartFilters-" + filter.Id);
var decodedFilter = SmartFilterHelper.Decode(filter.Filter);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber),
@ -324,7 +336,7 @@ public class OpdsController : BaseApiController
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
}
AddPagination(feed, series, $"{prefix}{apiKey}/smart-filter/{filterId}/");
AddPagination(feed, series, $"{prefix}{apiKey}/smart-filters/{filterId}/");
return CreateXmlResult(SerializeXml(feed));
}
@ -338,18 +350,20 @@ public class OpdsController : BaseApiController
var (_, prefix) = await GetPrefix();
var filters = _unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId);
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{prefix}{apiKey}/smart-filters", apiKey, prefix);
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{apiKey}/smart-filters", apiKey, prefix);
SetFeedId(feed, "smartFilters");
foreach (var filter in filters)
{
feed.Entries.Add(new FeedEntry()
{
Id = filter.Id.ToString(),
Title = filter.Name,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{filter.Id}")
}
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/smart-filters/{filter.Id}")
]
});
}
@ -367,7 +381,7 @@ public class OpdsController : BaseApiController
var (_, prefix) = await GetPrefix();
var externalSources = await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId);
var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{prefix}{apiKey}/external-sources", apiKey, prefix);
var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{apiKey}/external-sources", apiKey, prefix);
SetFeedId(feed, "externalSources");
foreach (var externalSource in externalSources)
{
@ -397,7 +411,7 @@ public class OpdsController : BaseApiController
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{prefix}{apiKey}/libraries", apiKey, prefix);
var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{apiKey}/libraries", apiKey, prefix);
SetFeedId(feed, "libraries");
// Ensure libraries follow SideNav order
@ -408,12 +422,15 @@ public class OpdsController : BaseApiController
{
Id = library!.Id.ToString(),
Title = library.Name!,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}")
}
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/libraries/{library.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}")
]
});
}
@ -448,29 +465,31 @@ public class OpdsController : BaseApiController
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync())
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId));
var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true);
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{apiKey}/collections", apiKey, prefix);
SetFeedId(feed, "collections");
feed.Entries.AddRange(tags.Select(tag => new FeedEntry()
{
Id = tag.Id.ToString(),
Title = tag.Title,
Summary = tag.Summary,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
}
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
]
}));
return CreateXmlResult(SerializeXml(feed));
@ -487,20 +506,9 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
IEnumerable <CollectionTagDto> tags;
if (isAdmin)
{
tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
}
else
{
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId);
}
var tag = tags.SingleOrDefault(t => t.Id == collectionId);
if (tag == null)
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId);
if (tag == null || (tag.AppUserId != user.Id && !tag.Promoted))
{
return BadRequest("Collection does not exist or you don't have access");
}
@ -508,7 +516,7 @@ public class OpdsController : BaseApiController
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, GetUserParams(pageNumber));
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
var feed = CreateFeed(tag.Title + " Collection", $"{prefix}{apiKey}/collections/{collectionId}", apiKey, prefix);
var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey, prefix);
SetFeedId(feed, $"collections-{collectionId}");
AddPagination(feed, series, $"{prefix}{apiKey}/collections/{collectionId}");
@ -534,8 +542,10 @@ public class OpdsController : BaseApiController
true, GetUserParams(pageNumber), false);
var feed = CreateFeed("All Reading Lists", $"{prefix}{apiKey}/reading-list", apiKey, prefix);
var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey, prefix);
SetFeedId(feed, "reading-list");
AddPagination(feed, readingLists, $"{prefix}{apiKey}/reading-list/");
foreach (var readingListDto in readingLists)
{
feed.Entries.Add(new FeedEntry()
@ -543,15 +553,19 @@ public class OpdsController : BaseApiController
Id = readingListDto.Id.ToString(),
Title = readingListDto.Title,
Summary = readingListDto.Summary,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}")
}
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}")
]
});
}
return CreateXmlResult(SerializeXml(feed));
}
@ -566,31 +580,50 @@ public class OpdsController : BaseApiController
[HttpGet("{apiKey}/reading-list/{readingListId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetReadingListItems(int readingListId, string apiKey)
public async Task<IActionResult> GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0)
{
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
var userWithLists = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user!.UserName!, AppUserIncludes.ReadingListsWithItems);
if (userWithLists == null) return Unauthorized();
var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
{
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
}
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null)
{
return Unauthorized();
}
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, user.Id);
if (readingList == null)
{
return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted"));
}
var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{prefix}{apiKey}/reading-list/{readingListId}", apiKey, prefix);
var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix);
SetFeedId(feed, $"reading-list-{readingListId}");
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
foreach (var item in items)
{
feed.Entries.Add(
CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}",
string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl));
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId);
// If there is only one file underneath, add a direct acquisition link, otherwise add a subsection
if (chapterDto != null && chapterDto.Files.Count == 1)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId);
feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId,
chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
}
else
{
feed.Entries.Add(
CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}",
item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl));
}
}
return CreateXmlResult(SerializeXml(feed));
}
@ -647,7 +680,7 @@ public class OpdsController : BaseApiController
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id));
var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{prefix}{apiKey}/recently-added", apiKey, prefix);
var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{apiKey}/recently-added", apiKey, prefix);
SetFeedId(feed, "recently-added");
AddPagination(feed, recentlyAdded, $"{prefix}{apiKey}/recently-added");
@ -671,7 +704,7 @@ public class OpdsController : BaseApiController
var seriesDtos = await _unitOfWork.SeriesRepository.GetMoreIn(userId, 0, genreId, GetUserParams(pageNumber));
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.Id));
var feed = CreateFeed(await _localizationService.Translate(userId, "more-in-genre", genre.Title), $"{prefix}{apiKey}/more-in-genre", apiKey, prefix);
var feed = CreateFeed(await _localizationService.Translate(userId, "more-in-genre", genre.Title), $"{apiKey}/more-in-genre", apiKey, prefix);
SetFeedId(feed, "more-in-genre");
AddPagination(feed, seriesDtos, $"{prefix}{apiKey}/more-in-genre");
@ -694,9 +727,8 @@ public class OpdsController : BaseApiController
var seriesDtos = (await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, PageSize)).ToList();
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.SeriesId));
var feed = CreateFeed(await _localizationService.Translate(userId, "recently-updated"), $"{prefix}{apiKey}/recently-updated", apiKey, prefix);
var feed = CreateFeed(await _localizationService.Translate(userId, "recently-updated"), $"{apiKey}/recently-updated", apiKey, prefix);
SetFeedId(feed, "recently-updated");
//AddPagination(feed, seriesDtos, $"{prefix}{apiKey}/recently-updated");
foreach (var groupedSeries in seriesDtos)
{
@ -730,7 +762,7 @@ public class OpdsController : BaseApiController
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
var feed = CreateFeed(await _localizationService.Translate(userId, "on-deck"), $"{prefix}{apiKey}/on-deck", apiKey, prefix);
var feed = CreateFeed(await _localizationService.Translate(userId, "on-deck"), $"{apiKey}/on-deck", apiKey, prefix);
SetFeedId(feed, "on-deck");
AddPagination(feed, pagedList, $"{prefix}{apiKey}/on-deck");
@ -742,6 +774,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)
@ -759,20 +797,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, $"{prefix}{apiKey}/series?query=" + query, apiKey, prefix);
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()
{
@ -791,7 +830,7 @@ public class OpdsController : BaseApiController
});
}
foreach (var readingListDto in series.ReadingLists)
foreach (var readingListDto in searchResults.ReadingLists)
{
feed.Entries.Add(new FeedEntry()
{
@ -805,6 +844,7 @@ public class OpdsController : BaseApiController
});
}
// TODO: Search should allow Chapters/Files and more
return CreateXmlResult(SerializeXml(feed));
}
@ -849,45 +889,64 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var feed = CreateFeed(series!.Name + " - Storyline", $"{prefix}{apiKey}/series/{series.Id}", apiKey, prefix);
var feed = CreateFeed(series!.Name + " - Storyline", $"{apiKey}/series/{series.Id}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}");
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
var chapterDict = new Dictionary<int, short>();
var fileDict = new Dictionary<int, short>();
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
foreach (var volume in seriesDetail.Volumes)
{
var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id)).OrderBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture),
_chapterSortComparer);
var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files | ChapterIncludes.People);
foreach (var chapterId in chapters.Select(c => c.Id))
foreach (var chapter in chaptersForVolume)
{
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
foreach (var mangaFile in files)
var chapterId = chapter.Id;
if (!chapterDict.TryAdd(chapterId, 0)) continue;
var chapterDto = _mapper.Map<ChapterDto>(chapter);
foreach (var mangaFile in chapter.Files)
{
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
// If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series,
chapterDto, apiKey, prefix, baseUrl));
}
}
}
foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
var chapters = seriesDetail.StorylineChapters;
if (!seriesDetail.StorylineChapters.Any() && seriesDetail.Chapters.Any())
{
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id);
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(storylineChapter.Id);
chapters = seriesDetail.Chapters;
}
foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id)))
{
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
var chapterDto = _mapper.Map<ChapterDto>(chapter);
foreach (var mangaFile in files)
{
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
// If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, chapter.VolumeId, chapter.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
chapterDto, apiKey, prefix, baseUrl));
}
}
foreach (var special in seriesDetail.Specials)
{
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id);
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(special.Id);
var chapterDto = _mapper.Map<ChapterDto>(special);
foreach (var mangaFile in files)
{
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
// If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
chapterDto, apiKey, prefix, baseUrl));
}
}
@ -904,18 +963,16 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var chapters =
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture),
_chapterSortComparer);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ",
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
$"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
foreach (var chapter in chapters)
foreach (var chapter in volume.Chapters)
{
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id);
foreach (var mangaFile in files)
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id, ChapterIncludes.Files | ChapterIncludes.People);
foreach (var mangaFile in chapterDto.Files)
{
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapter.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
}
@ -932,17 +989,20 @@ public class OpdsController : BaseApiController
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People);
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s",
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix);
$"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files");
foreach (var mangaFile in files)
foreach (var mangaFile in chapter.Files)
{
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl));
}
@ -968,7 +1028,7 @@ public class OpdsController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(await GetUser(apiKey));
if (!await _accountService.HasDownloadPermission(user))
{
return BadRequest("User does not have download permissions");
return Forbid("User does not have download permissions");
}
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
@ -986,7 +1046,7 @@ public class OpdsController : BaseApiController
};
}
private static void AddPagination(Feed feed, PagedList<SeriesDto> list, string href)
private static void AddPagination<T>(Feed feed, PagedList<T> list, string href)
{
var url = href;
if (href.Contains('?'))
@ -1032,22 +1092,21 @@ public class OpdsController : BaseApiController
Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary)
? string.Empty
: $" Summary: {metadata.Summary}"),
Authors = metadata.Writers.Select(p => new FeedAuthor()
{
Name = p.Name,
Uri = "http://opds-spec.org/author/" + p.Id
}).ToList(),
Authors = metadata.Writers.Select(CreateAuthor).ToList(),
Categories = metadata.Genres.Select(g => new FeedCategory()
{
Label = g.Title,
Term = string.Empty
}).ToList(),
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{seriesDto.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}")
}
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/series/{seriesDto.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}")
]
};
}
@ -1058,35 +1117,49 @@ public class OpdsController : BaseApiController
Id = searchResultDto.SeriesId.ToString(),
Title = $"{searchResultDto.Name}",
Summary = $"Format: {searchResultDto.Format}",
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}")
}
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}")
]
};
}
private static FeedEntry CreateChapter(string apiKey, string title, string summary, int chapterId, int volumeId, int seriesId, string prefix, string baseUrl)
private static FeedAuthor CreateAuthor(PersonDto person)
{
return new FeedAuthor()
{
Name = person.Name,
Uri = "http://opds-spec.org/author/" + person.Id
};
}
private static FeedEntry CreateChapter(string apiKey, string title, string? summary, int chapterId, int volumeId, int seriesId, string prefix, string baseUrl)
{
return new FeedEntry()
{
Id = chapterId.ToString(),
Title = title,
Summary = summary ?? string.Empty,
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"),
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}")
}
]
};
}
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId,
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
{
var fileSize =
mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) :
@ -1095,23 +1168,23 @@ public class OpdsController : BaseApiController
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath));
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey));
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId);
var title = $"{series.Name}";
if (volume!.Chapters.Count == 1)
if (volume!.Chapters.Count == 1 && !volume.IsSpecial())
{
var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty);
SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType, volumeLabel);
if (volume.Name != "0")
SeriesService.RenameVolumeName(volume, libraryType, volumeLabel);
if (!volume.IsLooseLeaf())
{
title += $" - {volume.Name}";
}
}
else if (volume.MinNumber != 0)
else if (!volume.IsLooseLeaf() && !volume.IsSpecial())
{
title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}";
title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}";
}
else
{
@ -1130,23 +1203,33 @@ public class OpdsController : BaseApiController
Id = mangaFile.Id.ToString(),
Title = title,
Extent = fileSize,
Summary = $"{fileType.Split("/")[1]} - {fileSize}",
Summary = $"File Type: {fileType.Split("/")[1]} - {fileSize}" + (string.IsNullOrWhiteSpace(chapter.Summary)
? string.Empty
: $" Summary: {chapter.Summary}"),
Format = mangaFile.Format.ToString(),
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
// We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
accLink,
await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix)
},
Links =
[
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
// We MUST include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
accLink
],
Content = new FeedEntryContent()
{
Text = fileType,
Type = "text"
}
},
Authors = chapter.Writers.Select(CreateAuthor).ToList()
};
var canPageStream = mangaFile.Extension != ".epub";
if (canPageStream)
{
entry.Links.Add(await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix));
}
return entry;
}
@ -1167,7 +1250,7 @@ public class OpdsController : BaseApiController
{
var userId = await GetUser(apiKey);
if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page"));
var chapter = await _cacheService.Ensure(chapterId);
var chapter = await _cacheService.Ensure(chapterId, true);
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "cache-file-find"));
try
@ -1193,10 +1276,9 @@ public class OpdsController : BaseApiController
SeriesId = seriesId,
VolumeId = volumeId,
LibraryId =libraryId
}, await GetUser(apiKey));
}, userId);
}
return File(content, MimeTypeMap.GetMimeType(format));
}
catch (Exception)
@ -1228,8 +1310,7 @@ public class OpdsController : BaseApiController
{
try
{
var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
return user;
return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
}
catch
{
@ -1238,7 +1319,7 @@ public class OpdsController : BaseApiController
throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
}
private async Task<FeedLink> CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey, string prefix)
private async Task<FeedLink> CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, string apiKey, string prefix)
{
var userId = await GetUser(apiKey);
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId);
@ -1247,12 +1328,14 @@ public class OpdsController : BaseApiController
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg",
$"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
link.TotalPages = mangaFile.Pages;
link.IsPageStream = true;
if (progress != null)
{
link.LastRead = progress.PageNum;
link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601
}
link.IsPageStream = true;
return link;
}
@ -1277,20 +1360,61 @@ public class OpdsController : BaseApiController
{
Title = title,
Icon = $"{prefix}{apiKey}/favicon",
Links = new List<FeedLink>()
{
Links =
[
link,
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"),
CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search")
},
],
};
}
private string SerializeXml(Feed feed)
private string SerializeXml(Feed? feed)
{
if (feed == null) return string.Empty;
// Remove invalid XML characters from the feed object
SanitizeFeed(feed);
using var sm = new StringWriter();
_xmlSerializer.Serialize(sm, feed);
return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds
var ret = sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds
return ret;
}
// Recursively sanitize all string properties in the object
private static void SanitizeFeed(object? obj)
{
if (obj == null) return;
var properties = obj.GetType().GetProperties();
foreach (var property in properties)
{
// Skip properties that require an index (e.g., indexed collections)
if (property.GetIndexParameters().Length > 0)
continue;
if (property.PropertyType == typeof(string) && property.CanWrite)
{
var value = (string?)property.GetValue(obj);
if (!string.IsNullOrEmpty(value))
{
property.SetValue(obj, RemoveInvalidXmlChars(value));
}
}
else if (property.PropertyType.IsClass) // Handle nested objects
{
var nestedObject = property.GetValue(obj);
if (nestedObject != null)
SanitizeFeed(nestedObject);
}
}
}
private static string RemoveInvalidXmlChars(string input)
{
return new string(input.Where(XmlConvert.IsXmlChar).ToArray());
}
}

View file

@ -1,6 +1,7 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.Progress;
using API.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

View file

@ -0,0 +1,177 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Tasks.Metadata;
using API.SignalR;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nager.ArticleNumber;
namespace API.Controllers;
#nullable enable
public class PersonController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly IMapper _mapper;
private readonly ICoverDbService _coverDbService;
private readonly IImageService _imageService;
private readonly IEventHub _eventHub;
public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper,
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_mapper = mapper;
_coverDbService = coverDbService;
_imageService = imageService;
_eventHub = eventHub;
}
[HttpGet]
public async Task<ActionResult<PersonDto>> GetPersonByName(string name)
{
return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId()));
}
/// <summary>
/// Returns all roles for a Person
/// </summary>
/// <param name="personId"></param>
/// <returns></returns>
[HttpGet("roles")]
public async Task<ActionResult<IEnumerable<PersonRole>>> GetRolesForPersonByName(int personId)
{
return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId()));
}
/// <summary>
/// Returns a list of authors and artists for browsing
/// </summary>
/// <param name="userParams"></param>
/// <returns></returns>
[HttpPost("all")]
public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetAuthorsForBrowse([FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list);
}
/// <summary>
/// Updates the Person
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult<PersonDto>> UpdatePerson(UpdatePersonDto dto)
{
// This needs to get all people and update them equally
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id);
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required"));
// Validate the name is unique
if (dto.Name != person.Name && !(await _unitOfWork.PersonRepository.IsNameUnique(dto.Name)))
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique"));
}
person.Name = dto.Name?.Trim();
person.Description = dto.Description ?? string.Empty;
person.CoverImageLocked = dto.CoverImageLocked;
if (dto.MalId is > 0)
{
person.MalId = (long) dto.MalId;
}
if (dto.AniListId is > 0)
{
person.AniListId = (int) dto.AniListId;
}
if (!string.IsNullOrEmpty(dto.HardcoverId?.Trim()))
{
person.HardcoverId = dto.HardcoverId.Trim();
}
var asin = dto.Asin?.Trim();
if (!string.IsNullOrEmpty(asin) &&
(ArticleNumberHelper.IsValidIsbn10(asin) || ArticleNumberHelper.IsValidIsbn13(asin)))
{
person.Asin = asin;
}
_unitOfWork.PersonRepository.Update(person);
await _unitOfWork.CommitAsync();
return Ok(_mapper.Map<PersonDto>(person));
}
/// <summary>
/// Attempts to download the cover from CoversDB (Note: Not yet release in Kavita)
/// </summary>
/// <param name="personId"></param>
/// <returns></returns>
[HttpPost("fetch-cover")]
public async Task<ActionResult<string>> DownloadCoverImage([FromQuery] int personId)
{
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var person = await _unitOfWork.PersonRepository.GetPersonById(personId);
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
var personImage = await _coverDbService.DownloadPersonImageAsync(person, settings.EncodeMediaAs);
if (string.IsNullOrEmpty(personImage))
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist"));
}
person.CoverImage = personImage;
_imageService.UpdateColorScape(person);
_unitOfWork.PersonRepository.Update(person);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(person.Id, "person"), false);
return Ok(personImage);
}
/// <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
/// </summary>
/// <param name="personId"></param>
/// <returns></returns>
[HttpGet("series-known-for")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetKnownSeries(int personId)
{
return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId));
}
/// <summary>
/// Returns all individual chapters by role. Limited to 20 results.
/// </summary>
/// <param name="personId"></param>
/// <param name="role"></param>
/// <returns></returns>
[HttpGet("chapters-by-role")]
public async Task<ActionResult<IEnumerable<StandaloneChapterDto>>> GetChaptersByRole(int personId, PersonRole role)
{
return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role));
}
}

View file

@ -46,6 +46,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService
}
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId);
logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
return new UserDto
{
Username = user.UserName!,

View file

@ -7,8 +7,8 @@ using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Progress;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
@ -21,7 +21,6 @@ using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using MimeTypes;
namespace API.Controllers;
@ -68,11 +67,11 @@ public class ReaderController : BaseApiController
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("pdf")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "apiKey"})]
public async Task<ActionResult> GetPdf(int chapterId, string apiKey)
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "apiKey"])]
public async Task<ActionResult> GetPdf(int chapterId, string apiKey, bool extractPdf = false)
{
if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest();
var chapter = await _cacheService.Ensure(chapterId);
var chapter = await _cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return NoContent();
// Validate the user has access to the PDF
@ -90,7 +89,7 @@ public class ReaderController : BaseApiController
}
catch (Exception)
{
_cacheService.CleanupChapters(new []{ chapterId });
_cacheService.CleanupChapters([chapterId]);
throw;
}
}
@ -105,7 +104,8 @@ public class ReaderController : BaseApiController
/// <param name="extractPdf">Should Kavita extract pdf into images. Defaults to false.</param>
/// <returns></returns>
[HttpGet("image")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "page", "extractPdf", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "page", "extractPdf", "apiKey"
])]
[AllowAnonymous]
public async Task<ActionResult> GetImage(int chapterId, int page, string apiKey, bool extractPdf = false)
{
@ -117,7 +117,7 @@ public class ReaderController : BaseApiController
{
var chapter = await _cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return NoContent();
_logger.LogInformation("Fetching Page {PageNum} on Chapter {ChapterId}", page, chapterId);
var path = _cacheService.GetCachedPagePath(chapter.Id, page);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path))
return BadRequest(await _localizationService.Translate(userId, "no-image-for-page", page));
@ -127,7 +127,7 @@ public class ReaderController : BaseApiController
}
catch (Exception)
{
_cacheService.CleanupChapters(new []{ chapterId });
_cacheService.CleanupChapters([chapterId]);
throw;
}
}
@ -140,7 +140,7 @@ public class ReaderController : BaseApiController
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpGet("thumbnail")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey"])]
[AllowAnonymous]
public async Task<ActionResult> GetThumbnail(int chapterId, int pageNum, string apiKey)
{
@ -164,14 +164,14 @@ public class ReaderController : BaseApiController
/// <remarks>We must use api key as bookmarks could be leaked to other users via the API</remarks>
/// <returns></returns>
[HttpGet("bookmark-image")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"seriesId", "page", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "page", "apiKey"])]
[AllowAnonymous]
public async Task<ActionResult> GetBookmarkImage(int seriesId, string apiKey, int page)
{
if (page < 0) page = 0;
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return Unauthorized();
if (page < 0) page = 0;
var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId);
if (page > totalPages)
{
@ -188,7 +188,7 @@ public class ReaderController : BaseApiController
}
catch (Exception)
{
_cacheService.CleanupBookmarks(new []{ seriesId });
_cacheService.CleanupBookmarks([seriesId]);
throw;
}
}
@ -202,12 +202,13 @@ public class ReaderController : BaseApiController
/// <param name="extractPdf"></param>
/// <returns></returns>
[HttpGet("file-dimensions")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "extractPdf"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf"])]
public async Task<ActionResult<IEnumerable<FileDimensionDto>>> GetFileDimensions(int chapterId, bool extractPdf = false)
{
if (chapterId <= 0) return ArraySegment<FileDimensionDto>.Empty;
var chapter = await _cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return NoContent();
return Ok(_cacheService.GetCachedFileDimensions(_cacheService.GetCachePath(chapterId)));
}
@ -220,7 +221,8 @@ public class ReaderController : BaseApiController
/// <param name="includeDimensions">Include file dimensions. Only useful for image based reading</param>
/// <returns></returns>
[HttpGet("chapter-info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "extractPdf", "includeDimensions"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"
])]
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false)
{
if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore
@ -261,13 +263,14 @@ public class ReaderController : BaseApiController
}
if (info.ChapterTitle is {Length: > 0}) {
// TODO: Can we rework the logic of generating titles for the UI and instead calculate that in the DB?
info.Title += " - " + info.ChapterTitle;
}
if (info.IsSpecial && dto.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume))
if (info.IsSpecial)
{
info.Subtitle = info.FileName;
} else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume))
info.Subtitle = Path.GetFileNameWithoutExtension(info.FileName);
} else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume))
{
info.Subtitle = ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber;
}
@ -293,7 +296,7 @@ public class ReaderController : BaseApiController
/// <param name="includeDimensions">Include file dimensions (extra I/O). Defaults to true.</param>
/// <returns></returns>
[HttpGet("bookmark-info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"seriesId", "includeDimensions"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "includeDimensions"])]
public async Task<ActionResult<BookmarkInfoDto>> GetBookmarkInfo(int seriesId, bool includeDimensions = true)
{
var totalPages = await _cacheService.CacheBookmarkForSeries(User.GetUserId(), seriesId);
@ -377,13 +380,10 @@ public class ReaderController : BaseApiController
var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId);
await _readerService.MarkChaptersAsUnread(user, markVolumeReadDto.SeriesId, chapters);
if (await _unitOfWork.CommitAsync())
{
BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markVolumeReadDto.SeriesId));
return Ok();
}
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress"));
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress"));
BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markVolumeReadDto.SeriesId));
return Ok();
}
/// <summary>
@ -542,6 +542,8 @@ public class ReaderController : BaseApiController
public async Task<ActionResult<ProgressDto>> GetProgress(int chapterId)
{
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, User.GetUserId());
_logger.LogDebug("Get Progress for {ChapterId} is {Pages}", chapterId, progress?.PageNum ?? 0);
if (progress == null) return Ok(new ProgressDto()
{
PageNum = 0,
@ -553,7 +555,7 @@ public class ReaderController : BaseApiController
}
/// <summary>
/// Save page against Chapter for logged in user
/// Save page against Chapter for authenticated user
/// </summary>
/// <param name="progressDto"></param>
/// <returns></returns>
@ -750,7 +752,7 @@ public class ReaderController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
if (user == null) return new UnauthorizedResult();
if (user.Bookmarks.IsNullOrEmpty()) return Ok();
if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok();
if (!await _accountService.HasBookmarkPermission(user))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
@ -771,7 +773,7 @@ public class ReaderController : BaseApiController
/// <param name="volumeId"></param>
/// <param name="currentChapterId"></param>
/// <returns>chapter id for next manga</returns>
[ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId", "volumeId", "currentChapterId"})]
[ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId", "volumeId", "currentChapterId"])]
[HttpGet("next-chapter")]
public async Task<ActionResult<int>> GetNextChapter(int seriesId, int volumeId, int currentChapterId)
{
@ -789,7 +791,7 @@ public class ReaderController : BaseApiController
/// <param name="volumeId"></param>
/// <param name="currentChapterId"></param>
/// <returns>chapter id for next manga</returns>
[ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId", "volumeId", "currentChapterId"})]
[ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId", "volumeId", "currentChapterId"])]
[HttpGet("prev-chapter")]
public async Task<ActionResult<int>> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId)
{
@ -803,7 +805,7 @@ public class ReaderController : BaseApiController
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("time-left")]
[ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId"])]
public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId)
{
var userId = User.GetUserId();
@ -837,16 +839,26 @@ public class ReaderController : BaseApiController
return Ok(_unitOfWork.UserTableOfContentRepository.GetPersonalToC(User.GetUserId(), chapterId));
}
/// <summary>
/// Deletes the user's personal table of content for the given chapter
/// </summary>
/// <param name="chapterId"></param>
/// <param name="pageNum"></param>
/// <param name="title"></param>
/// <returns></returns>
[HttpDelete("ptoc")]
public async Task<ActionResult> DeletePersonalToc([FromQuery] int chapterId, [FromQuery] int pageNum, [FromQuery] string title)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(title)) return BadRequest(await _localizationService.Translate(userId, "name-required"));
if (pageNum < 0) return BadRequest(await _localizationService.Translate(userId, "valid-number"));
var toc = await _unitOfWork.UserTableOfContentRepository.Get(userId, chapterId, pageNum, title);
if (toc == null) return Ok();
_unitOfWork.UserTableOfContentRepository.Remove(toc);
await _unitOfWork.CommitAsync();
return Ok();
}
@ -882,4 +894,17 @@ public class ReaderController : BaseApiController
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Get all progress events for a given chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("all-chapter-progress")]
public async Task<ActionResult<IEnumerable<FullProgressDto>>> GetProgressForChapter(int chapterId)
{
var userId = User.IsInRole(PolicyConstants.AdminRole) ? 0 : User.GetUserId();
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, userId));
}
}

View file

@ -6,10 +6,10 @@ using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.ReadingLists;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -24,13 +24,15 @@ public class ReadingListController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly IReadingListService _readingListService;
private readonly ILocalizationService _localizationService;
private readonly IReaderService _readerService;
public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService,
ILocalizationService localizationService)
ILocalizationService localizationService, IReaderService readerService)
{
_unitOfWork = unitOfWork;
_readingListService = readingListService;
_localizationService = localizationService;
_readerService = readerService;
}
/// <summary>
@ -39,9 +41,15 @@ public class ReadingListController : BaseApiController
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetList(int readingListId)
public async Task<ActionResult<ReadingListDto>> GetList(int readingListId)
{
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId()));
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId());
if (readingList == null)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-restricted"));
}
return Ok(readingList);
}
/// <summary>
@ -63,7 +71,7 @@ public class ReadingListController : BaseApiController
}
/// <summary>
/// Returns all Reading Lists the user has access to that have a series within it.
/// Returns all Reading Lists the user has access to that the given series within it.
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
@ -74,6 +82,18 @@ public class ReadingListController : BaseApiController
seriesId, true));
}
/// <summary>
/// Returns all Reading Lists the user has access to that has the given chapter within it.
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("lists-for-chapter")]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForChapter(int chapterId)
{
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtosForChapterAndUserAsync(User.GetUserId(),
chapterId, true));
}
/// <summary>
/// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress
/// </summary>
@ -96,6 +116,7 @@ public class ReadingListController : BaseApiController
[HttpPost("update-position")]
public async Task<ActionResult> UpdateListItemPosition(UpdateReadingListPosition dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
// Make sure UI buffers events
var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername());
if (user == null)
@ -110,13 +131,14 @@ public class ReadingListController : BaseApiController
}
/// <summary>
/// Deletes a list item from the list. Will reorder all item positions afterwards
/// Deletes a list item from the list. Item orders will update as a result.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete-item")]
public async Task<ActionResult> DeleteListItem(UpdateReadingListPosition dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername());
if (user == null)
{
@ -139,6 +161,8 @@ public class ReadingListController : BaseApiController
[HttpPost("remove-read")]
public async Task<ActionResult> DeleteReadFromList([FromQuery] int readingListId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername());
if (user == null)
{
@ -150,7 +174,7 @@ public class ReadingListController : BaseApiController
return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated"));
}
return BadRequest("Couldn't delete item(s)");
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-item-delete"));
}
/// <summary>
@ -161,6 +185,7 @@ public class ReadingListController : BaseApiController
[HttpDelete]
public async Task<ActionResult> DeleteList([FromQuery] int readingListId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername());
if (user == null)
{
@ -181,6 +206,7 @@ public class ReadingListController : BaseApiController
[HttpPost("create")]
public async Task<ActionResult<ReadingListDto>> CreateList(CreateReadingListDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingLists);
if (user == null) return Unauthorized();
@ -204,6 +230,7 @@ public class ReadingListController : BaseApiController
[HttpPost("update")]
public async Task<ActionResult> UpdateList(UpdateReadingListDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist"));
@ -233,6 +260,7 @@ public class ReadingListController : BaseApiController
[HttpPost("update-by-series")]
public async Task<ActionResult> UpdateListBySeries(UpdateReadingListBySeriesDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername());
if (user == null)
{
@ -242,7 +270,7 @@ public class ReadingListController : BaseApiController
var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist"));
var chapterIdsForSeries =
await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId});
await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync([dto.SeriesId]);
// If there are adds, tell tracking this has been modified
if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList))
@ -275,6 +303,7 @@ public class ReadingListController : BaseApiController
[HttpPost("update-by-multiple")]
public async Task<ActionResult> UpdateListByMultiple(UpdateReadingListByMultipleDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername());
if (user == null)
{
@ -319,6 +348,7 @@ public class ReadingListController : BaseApiController
[HttpPost("update-by-multiple-series")]
public async Task<ActionResult> UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername());
if (user == null)
{
@ -357,6 +387,7 @@ public class ReadingListController : BaseApiController
[HttpPost("update-by-volume")]
public async Task<ActionResult> UpdateListByVolume(UpdateReadingListByVolumeDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername());
if (user == null)
{
@ -393,6 +424,7 @@ public class ReadingListController : BaseApiController
[HttpPost("update-by-chapter")]
public async Task<ActionResult> UpdateListByChapter(UpdateReadingListByChapterDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername());
if (user == null)
{
@ -423,26 +455,38 @@ public class ReadingListController : BaseApiController
return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
}
/// <summary>
/// Returns a list of characters associated with the reading list
/// Returns a list of a given role associated with the reading list
/// </summary>
/// <param name="readingListId"></param>
/// <param name="role">PersonRole</param>
/// <returns></returns>
[HttpGet("people")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId", "role"])]
public ActionResult<IEnumerable<PersonDto>> GetPeopleByRoleForList(int readingListId, PersonRole role)
{
return Ok(_unitOfWork.ReadingListRepository.GetReadingListPeopleAsync(readingListId, role));
}
/// <summary>
/// Returns all people in given roles for a reading list
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("characters")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)]
public ActionResult<IEnumerable<PersonDto>> GetCharactersForList(int readingListId)
[HttpGet("all-people")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId"])]
public async Task<ActionResult<IEnumerable<PersonDto>>> GetAllPeopleForList(int readingListId)
{
return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId));
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListAllPeopleAsync(readingListId));
}
/// <summary>
/// Returns the next chapter within the reading list
/// </summary>
/// <param name="currentChapterId"></param>
/// <param name="readingListId"></param>
/// <returns>Chapter Id for next item, -1 if nothing exists</returns>
/// <returns>Chapter ID for next item, -1 if nothing exists</returns>
[HttpGet("next-chapter")]
public async Task<ActionResult<int>> GetNextChapter(int currentChapterId, int readingListId)
{
@ -491,4 +535,83 @@ public class ReadingListController : BaseApiController
if (string.IsNullOrEmpty(name)) return true;
return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name));
}
/// <summary>
/// Promote/UnPromote multiple reading lists in one go. Will only update the authenticated user's reading lists and will only work if the user has promotion role
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("promote-multiple")]
public async Task<ActionResult> PromoteMultipleReadingLists(PromoteReadingListsDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
// This needs to take into account owner as I can select other users cards
var userId = User.GetUserId();
if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole))
{
return BadRequest(await _localizationService.Translate(userId, "permission-denied"));
}
var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsByIds(dto.ReadingListIds);
foreach (var readingList in readingLists)
{
if (readingList.AppUserId != userId) continue;
readingList.Promoted = dto.Promoted;
_unitOfWork.ReadingListRepository.Update(readingList);
}
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Delete multiple reading lists in one go
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete-multiple")]
public async Task<ActionResult> DeleteMultipleReadingLists(DeleteReadingListsDto dto)
{
// This needs to take into account owner as I can select other users cards
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ReadingLists);
if (user == null) return Unauthorized();
user.ReadingLists = user.ReadingLists.Where(uc => !dto.ReadingListIds.Contains(uc.Id)).ToList();
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Returns random information about a Reading List
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["readingListId"])]
public async Task<ActionResult<ReadingListInfoDto?>> GetReadingListInfo(int readingListId)
{
var result = await _unitOfWork.ReadingListRepository.GetReadingListInfoAsync(readingListId);
if (result == null) return Ok(null);
var timeEstimate = _readerService.GetTimeEstimate(result.WordCount, result.Pages, result.IsAllEpub);
result.MinHoursToRead = timeEstimate.MinHours;
result.AvgHoursToRead = timeEstimate.AvgHours;
result.MaxHoursToRead = timeEstimate.MaxHours;
return Ok(result);
}
}

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;
@ -52,13 +53,30 @@ public class ScrobblingController : BaseApiController
return Ok(user.AniListAccessToken);
}
/// <summary>
/// Get the current user's MAL token and username
/// </summary>
/// <returns></returns>
[HttpGet("mal-token")]
public async Task<ActionResult<MalUserInfoDto>> GetMalToken()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
return Ok(new MalUserInfoDto()
{
Username = user.MalUserName,
AccessToken = user.MalAccessToken
});
}
/// <summary>
/// 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();
@ -68,10 +86,38 @@ public class ScrobblingController : BaseApiController
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
if (isNewToken)
{
BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(user.Id));
}
return Ok(isNewToken);
}
/// <summary>
/// Update the current user's MAL token (Client ID) and Username
/// </summary>
/// <param name="dto"></param>
/// <returns>True if the token was new or not</returns>
[HttpPost("update-mal-token")]
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();
}
@ -224,4 +270,15 @@ public class ScrobblingController : BaseApiController
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Has the logged in user ran scrobble generation
/// </summary>
/// <returns></returns>
[HttpGet("has-ran-scrobble-gen")]
public async Task<ActionResult<bool>> HasRanScrobbleGen()
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
return Ok(user is {HasRunScrobbleEventGeneration: true});
}
}

View file

@ -50,20 +50,26 @@ public class SearchController : BaseApiController
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, User.GetUserId()));
}
/// <summary>
/// Searches against different entities in the system against a query string
/// </summary>
/// <param name="queryString"></param>
/// <param name="includeChapterAndFiles">Include Chapter and Filenames in the entities. This can slow down the search on larger systems</param>
/// <returns></returns>
[HttpGet("search")]
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString, [FromQuery] bool includeChapterAndFiles = true)
{
queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
if (!libraries.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin,
libraries, queryString);
libraries, queryString, includeChapterAndFiles);
return Ok(series);
}

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;
@ -18,11 +19,13 @@ using API.Helpers;
using API.Services;
using API.Services.Plus;
using EasyCaching.Core;
using Hangfire;
using Kavita.Common;
using Kavita.Common.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
@ -38,14 +41,17 @@ public class SeriesController : BaseApiController
private readonly ILicenseService _licenseService;
private readonly ILocalizationService _localizationService;
private readonly IExternalMetadataService _externalMetadataService;
private readonly IHostEnvironment _environment;
private readonly IEasyCachingProvider _externalSeriesCacheProvider;
private readonly IEasyCachingProvider _matchSeriesCacheProvider;
private const string CacheKey = "externalSeriesData_";
private const string MatchSeriesCacheKey = "matchSeries_";
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork,
ISeriesService seriesService, ILicenseService licenseService,
IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService,
IExternalMetadataService externalMetadataService)
IExternalMetadataService externalMetadataService, IHostEnvironment environment)
{
_logger = logger;
_taskScheduler = taskScheduler;
@ -54,8 +60,10 @@ public class SeriesController : BaseApiController
_licenseService = licenseService;
_localizationService = localizationService;
_externalMetadataService = externalMetadataService;
_environment = environment;
_externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
_matchSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusMatchSeries);
}
/// <summary>
@ -91,7 +99,7 @@ public class SeriesController : BaseApiController
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("v2")]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto)
public async Task<ActionResult<PagedList<SeriesDto>>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto)
{
var userId = User.GetUserId();
var series =
@ -134,7 +142,7 @@ public class SeriesController : BaseApiController
var username = User.GetUsername();
_logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username);
return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId}));
return Ok(await _seriesService.DeleteMultipleSeries([seriesId]));
}
[Authorize(Policy = "RequireAdminRole")]
@ -176,6 +184,7 @@ public class SeriesController : BaseApiController
return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter));
}
[Obsolete("All chapter entities will load this data by default. Will not be maintained as of v0.8.1")]
[HttpGet("chapter-metadata")]
public async Task<ActionResult<ChapterMetadataDto>> GetChapterMetadata(int chapterId)
{
@ -228,22 +237,26 @@ public class SeriesController : BaseApiController
{
// Trigger a refresh when we are moving from a locked image to a non-locked
needsRefreshMetadata = true;
series.CoverImage = string.Empty;
series.CoverImageLocked = updateSeries.CoverImageLocked;
series.CoverImage = null;
series.CoverImageLocked = false;
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id);
series.ResetColorScape();
}
_unitOfWork.SeriesRepository.Update(series);
if (await _unitOfWork.CommitAsync())
if (!await _unitOfWork.CommitAsync())
{
if (needsRefreshMetadata)
{
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id);
}
return Ok();
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-series-update"));
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-series-update"));
if (needsRefreshMetadata)
{
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id);
}
return Ok();
}
/// <summary>
@ -315,11 +328,12 @@ public class SeriesController : BaseApiController
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpPost("all-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams,
[FromQuery] int libraryId = 0, [FromQuery] QueryContext context = QueryContext.None)
{
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto);
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series"));
@ -339,7 +353,7 @@ public class SeriesController : BaseApiController
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpPost("all")]
[Obsolete("User all-v2")]
[Obsolete("Use all-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = User.GetUserId();
@ -398,7 +412,7 @@ public class SeriesController : BaseApiController
[HttpPost("refresh-metadata")]
public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto)
{
_taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate);
_taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate, refreshSeriesDto.ForceColorscape);
return Ok();
}
@ -495,7 +509,7 @@ public class SeriesController : BaseApiController
/// <param name="ageRating"></param>
/// <returns></returns>
/// <remarks>This is cached for an hour</remarks>
[ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})]
[ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = ["ageRating"])]
[HttpGet("age-rating")]
public async Task<ActionResult<string>> GetAgeRating(int ageRating)
{
@ -611,4 +625,52 @@ 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)
{
var cacheKey = $"{MatchSeriesCacheKey}-{dto.SeriesId}-{dto.Query}";
var results = await _matchSeriesCacheProvider.GetAsync<IList<ExternalSeriesMatchDto>>(cacheKey);
if (results.HasValue && !_environment.IsDevelopment())
{
return Ok(results.Value);
}
var ret = await _externalMetadataService.MatchSeries(dto);
await _matchSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(1));
return Ok(ret);
}
/// <summary>
/// This will perform the fix match
/// </summary>
/// <param name="match"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("update-match")]
public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int? aniListId, [FromQuery] long? malId, [FromQuery] int? cbrId)
{
BackgroundJob.Enqueue(() => _externalMetadataService.FixSeriesMatch(seriesId, aniListId, malId, cbrId));
return Ok();
}
/// <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

@ -88,6 +88,19 @@ public class ServerController : BaseApiController
return Ok();
}
/// <summary>
/// Performs the nightly maintenance work on the Server. Can be heavy.
/// </summary>
/// <returns></returns>
[HttpPost("cleanup")]
public ActionResult Cleanup()
{
_logger.LogInformation("{UserName} is clearing running general cleanup from admin dashboard", User.GetUsername());
RecurringJob.TriggerJob(TaskScheduler.CleanupTaskId);
return Ok();
}
/// <summary>
/// Performs an ad-hoc backup of the Database
/// </summary>
@ -116,15 +129,6 @@ public class ServerController : BaseApiController
return Ok();
}
/// <summary>
/// Returns non-sensitive information about the current system
/// </summary>
/// <returns></returns>
[HttpGet("server-info")]
public async Task<ActionResult<ServerInfoDto>> GetVersion()
{
return Ok(await _statsService.GetServerInfo());
}
/// <summary>
/// Returns non-sensitive information about the current system
@ -132,7 +136,7 @@ public class ServerController : BaseApiController
/// <remarks>This is just for the UI and is extremely lightweight</remarks>
/// <returns></returns>
[HttpGet("server-info-slim")]
public async Task<ActionResult<ServerInfoDto>> GetSlimVersion()
public async Task<ActionResult<ServerInfoSlimDto>> GetSlimVersion()
{
return Ok(await _statsService.GetServerInfoSlim());
}
@ -199,21 +203,27 @@ public class ServerController : BaseApiController
/// <summary>
/// Returns how many versions out of date this install is
/// </summary>
/// <param name="stableOnly">Only count Stable releases</param>
[HttpGet("check-out-of-date")]
public async Task<ActionResult<int>> CheckHowOutOfDate()
public async Task<ActionResult<int>> CheckHowOutOfDate(bool stableOnly = true)
{
return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind());
return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind(stableOnly));
}
/// <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)
{
return Ok(await _versionUpdaterService.GetAllReleases());
// Strange bug where [Authorize] doesn't work
if (User.GetUserId() == 0) return Unauthorized();
return Ok(await _versionUpdaterService.GetAllReleases(count));
}
/// <summary>
@ -221,18 +231,18 @@ public class ServerController : BaseApiController
/// </summary>
/// <returns></returns>
[HttpGet("jobs")]
public ActionResult<IEnumerable<JobDto>> GetJobs()
public async Task<ActionResult<IEnumerable<JobDto>>> GetJobs()
{
var recurringJobs = JobStorage.Current.GetConnection().GetRecurringJobs().Select(
dto =>
new JobDto() {
Id = dto.Id,
Title = dto.Id.Replace('-', ' '),
Cron = dto.Cron,
LastExecutionUtc = dto.LastExecution.HasValue ? new DateTime(dto.LastExecution.Value.Ticks, DateTimeKind.Utc) : null
});
var jobDtoTasks = JobStorage.Current.GetConnection().GetRecurringJobs().Select(async dto =>
new JobDto()
{
Id = dto.Id,
Title = await _localizationService.Translate(User.GetUserId(), dto.Id),
Cron = dto.Cron,
LastExecutionUtc = dto.LastExecution.HasValue ? new DateTime(dto.LastExecution.Value.Ticks, DateTimeKind.Utc) : null
});
return Ok(recurringJobs);
return Ok(await Task.WhenAll(jobDtoTasks));
}
/// <summary>
@ -273,4 +283,16 @@ public class ServerController : BaseApiController
return Ok();
}
/// <summary>
/// Runs the Sync Themes task
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpPost("sync-themes")]
public async Task<ActionResult> SyncThemes()
{
await _taskScheduler.SyncThemes();
return Ok();
}
}

View file

@ -5,6 +5,7 @@ using System.Net;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Email;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
@ -23,6 +24,7 @@ using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Swashbuckle.AspNetCore.Annotations;
namespace API.Controllers;
@ -32,27 +34,26 @@ public class SettingsController : BaseApiController
{
private readonly ILogger<SettingsController> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ITaskScheduler _taskScheduler;
private readonly IDirectoryService _directoryService;
private readonly IMapper _mapper;
private readonly IEmailService _emailService;
private readonly ILibraryWatcher _libraryWatcher;
private readonly ILocalizationService _localizationService;
private readonly ISettingsService _settingsService;
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler,
IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher,
ILocalizationService localizationService)
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, IMapper mapper,
IEmailService emailService, ILocalizationService localizationService, ISettingsService settingsService)
{
_logger = logger;
_unitOfWork = unitOfWork;
_taskScheduler = taskScheduler;
_directoryService = directoryService;
_mapper = mapper;
_emailService = emailService;
_libraryWatcher = libraryWatcher;
_localizationService = localizationService;
_settingsService = settingsService;
}
/// <summary>
/// Returns the base url for this instance (if set)
/// </summary>
/// <returns></returns>
[HttpGet("base-url")]
public async Task<ActionResult<string>> GetBaseUrl()
{
@ -137,324 +138,31 @@ public class SettingsController : BaseApiController
}
/// <summary>
/// Update Server settings
/// </summary>
/// <param name="updateSettingsDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost]
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
{
_logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername());
// We do not allow CacheDirectory changes, so we will ignore.
var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync();
var updateBookmarks = false;
var originalBookmarkDirectory = _directoryService.BookmarkDirectory;
var bookmarkDirectory = updateSettingsDto.BookmarksDirectory;
if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") &&
!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/"))
{
bookmarkDirectory =
_directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks");
}
if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory))
{
bookmarkDirectory = _directoryService.BookmarkDirectory;
}
foreach (var setting in currentSettings)
{
UpdateSchedulingSettings(setting, updateSettingsDto);
if (setting.Key == ServerSettingKey.OnDeckProgressDays &&
updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.OnDeckUpdateDays &&
updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.CoverImageSize &&
updateSettingsDto.CoverImageSize + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.CoverImageSize + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value)
{
if (OsInfo.IsDocker) continue;
setting.Value = updateSettingsDto.Port + string.Empty;
// Port is managed in appSetting.json
Configuration.Port = updateSettingsDto.Port;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.CacheSize &&
updateSettingsDto.CacheSize + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.CacheSize + string.Empty;
// CacheSize is managed in appSetting.json
Configuration.CacheSize = updateSettingsDto.CacheSize;
_unitOfWork.SettingsRepository.Update(setting);
}
UpdateEmailSettings(setting, updateSettingsDto);
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
{
if (OsInfo.IsDocker) continue;
// Validate IP addresses
foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',',
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (!IPAddress.TryParse(ipAddress.Trim(), out _))
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid",
ipAddress));
}
}
setting.Value = updateSettingsDto.IpAddresses;
// IpAddresses is managed in appSetting.json
Configuration.IpAddresses = updateSettingsDto.IpAddresses;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value)
{
var path = !updateSettingsDto.BaseUrl.StartsWith('/')
? $"/{updateSettingsDto.BaseUrl}"
: updateSettingsDto.BaseUrl;
path = !path.EndsWith('/')
? $"{path}/"
: path;
setting.Value = path;
Configuration.BaseUrl = updateSettingsDto.BaseUrl;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.LoggingLevel &&
updateSettingsDto.LoggingLevel + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.LoggingLevel + string.Empty;
LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel);
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EnableOpds &&
updateSettingsDto.EnableOpds + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableOpds + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EncodeMediaAs &&
updateSettingsDto.EncodeMediaAs + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EncodeMediaAs + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value)
{
setting.Value = (updateSettingsDto.HostName + string.Empty).Trim();
setting.Value = UrlHelper.RemoveEndingSlash(setting.Value);
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value)
{
// Validate new directory can be used
if (!await _directoryService.CheckWriteAccess(bookmarkDirectory))
{
return BadRequest(
await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions"));
}
originalBookmarkDirectory = setting.Value;
// Normalize the path deliminators. Just to look nice in DB, no functionality
setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory);
_unitOfWork.SettingsRepository.Update(setting);
updateBookmarks = true;
}
if (setting.Key == ServerSettingKey.AllowStatCollection &&
updateSettingsDto.AllowStatCollection + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.AllowStatCollection + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
if (!updateSettingsDto.AllowStatCollection)
{
_taskScheduler.CancelStatsTasks();
}
else
{
await _taskScheduler.ScheduleStatsTasks();
}
}
if (setting.Key == ServerSettingKey.TotalBackups &&
updateSettingsDto.TotalBackups + string.Empty != setting.Value)
{
if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-backups"));
}
setting.Value = updateSettingsDto.TotalBackups + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.TotalLogs &&
updateSettingsDto.TotalLogs + string.Empty != setting.Value)
{
if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-logs"));
}
setting.Value = updateSettingsDto.TotalLogs + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EnableFolderWatching &&
updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
}
if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto);
try
{
await _unitOfWork.CommitAsync();
if (updateBookmarks)
{
_directoryService.ExistOrCreate(bookmarkDirectory);
_directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory);
_directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory);
}
if (updateSettingsDto.EnableFolderWatching)
{
await _libraryWatcher.StartWatching();
}
else
{
_libraryWatcher.StopWatching();
}
var d = await _settingsService.UpdateSettings(updateSettingsDto);
return Ok(d);
}
catch (KavitaException ex)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when updating server settings");
await _unitOfWork.RollbackAsync();
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
_logger.LogInformation("Server Settings updated");
await _taskScheduler.ScheduleTasks();
return Ok(updateSettingsDto);
}
private void UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
{
if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value)
{
setting.Value = updateSettingsDto.TaskBackup;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value)
{
setting.Value = updateSettingsDto.TaskScan;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value)
{
setting.Value = updateSettingsDto.TaskCleanup;
_unitOfWork.SettingsRepository.Update(setting);
}
}
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
{
if (setting.Key == ServerSettingKey.EmailHost &&
updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailPort &&
updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailAuthPassword &&
updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailAuthUserName &&
updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailSenderAddress &&
updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailSenderDisplayName &&
updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailSizeLimit &&
updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailEnableSsl &&
updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailCustomizedTemplates &&
updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
}
/// <summary>
@ -510,6 +218,39 @@ public class SettingsController : BaseApiController
public async Task<ActionResult<EmailTestResultDto>> TestEmailServiceUrl()
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
if (string.IsNullOrEmpty(user?.Email)) return BadRequest("Your account has no email on record. Cannot email.");
return Ok(await _emailService.SendTestEmail(user!.Email));
}
/// <summary>
/// Get the metadata settings for Kavita+ users.
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("metadata-settings")]
public async Task<ActionResult<MetadataSettingsDto>> GetMetadataSettings()
{
return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto());
}
/// <summary>
/// Update the metadata settings for Kavita+ Metadata feature
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("metadata-settings")]
public async Task<ActionResult<MetadataSettingsDto>> UpdateMetadataSettings(MetadataSettingsDto dto)
{
try
{
return Ok(await _settingsService.UpdateMetadataSettings(dto));
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue when updating metadata settings");
return BadRequest(ex.Message);
}
}
}

View file

@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
@ -7,10 +10,15 @@ using API.DTOs.Statistics;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
using CsvHelper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using MimeTypes;
namespace API.Controllers;
@ -22,14 +30,19 @@ public class StatsController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<AppUser> _userManager;
private readonly ILocalizationService _localizationService;
private readonly ILicenseService _licenseService;
private readonly IDirectoryService _directoryService;
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
UserManager<AppUser> userManager, ILocalizationService localizationService)
UserManager<AppUser> userManager, ILocalizationService localizationService,
ILicenseService licenseService, IDirectoryService directoryService)
{
_statService = statService;
_unitOfWork = unitOfWork;
_userManager = userManager;
_localizationService = localizationService;
_licenseService = licenseService;
_directoryService = directoryService;
}
[HttpGet("user/{userId}/read")]
@ -108,6 +121,34 @@ public class StatsController : BaseApiController
return Ok(await _statService.GetFileBreakdown());
}
/// <summary>
/// Generates a csv of all file paths for a given extension
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("server/file-extension")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult> DownloadFilesByExtension(string fileExtension)
{
if (!Regex.IsMatch(fileExtension, Parser.SupportedExtensions))
{
return BadRequest("Invalid file format");
}
var tempFile = Path.Join(_directoryService.TempDirectory,
$"file_breakdown_{fileExtension.Replace(".", string.Empty)}.csv");
if (!_directoryService.FileSystem.File.Exists(tempFile))
{
var results = await _statService.GetFilesByExtension(fileExtension);
await using var writer = new StreamWriter(tempFile);
await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
await csv.WriteRecordsAsync(results);
}
return PhysicalFile(tempFile, MimeTypeMap.GetMimeType(Path.GetExtension(tempFile)),
System.Web.HttpUtility.UrlEncode(Path.GetFileName(tempFile)), true);
}
/// <summary>
/// Returns reading history events for a give or all users, broken up by day, and format
@ -181,6 +222,4 @@ public class StatsController : BaseApiController
return Ok(_statService.GetWordsReadCountByYear(userId));
}
}

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Dashboard;
using API.DTOs.SideNav;
@ -19,11 +20,13 @@ public class StreamController : BaseApiController
{
private readonly IStreamService _streamService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
public StreamController(IStreamService streamService, IUnitOfWork unitOfWork)
public StreamController(IStreamService streamService, IUnitOfWork unitOfWork, ILocalizationService localizationService)
{
_streamService = streamService;
_unitOfWork = unitOfWork;
_localizationService = localizationService;
}
/// <summary>
@ -74,6 +77,7 @@ public class StreamController : BaseApiController
[HttpPost("update-external-source")]
public async Task<ActionResult<ExternalSourceDto>> UpdateExternalSource(ExternalSourceDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
// Check if a host and api key exists for the current user
return Ok(await _streamService.UpdateExternalSource(User.GetUserId(), dto));
}
@ -86,7 +90,8 @@ public class StreamController : BaseApiController
[HttpGet("external-source-exists")]
public async Task<ActionResult<bool>> ExternalSourceExists(string host, string name, string apiKey)
{
return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), host, name, apiKey));
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), name, host, apiKey));
}
/// <summary>
@ -97,6 +102,7 @@ public class StreamController : BaseApiController
[HttpDelete("delete-external-source")]
public async Task<ActionResult> ExternalSourceExists(int externalSourceId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
await _streamService.DeleteExternalSource(User.GetUserId(), externalSourceId);
return Ok();
}
@ -110,6 +116,7 @@ public class StreamController : BaseApiController
[HttpPost("add-dashboard-stream")]
public async Task<ActionResult<DashboardStreamDto>> AddDashboard([FromQuery] int smartFilterId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
return Ok(await _streamService.CreateDashboardStreamFromSmartFilter(User.GetUserId(), smartFilterId));
}
@ -121,6 +128,7 @@ public class StreamController : BaseApiController
[HttpPost("update-dashboard-stream")]
public async Task<ActionResult> UpdateDashboardStream(DashboardStreamDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
await _streamService.UpdateDashboardStream(User.GetUserId(), dto);
return Ok();
}
@ -133,6 +141,7 @@ public class StreamController : BaseApiController
[HttpPost("update-dashboard-position")]
public async Task<ActionResult> UpdateDashboardStreamPosition(UpdateStreamPositionDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
await _streamService.UpdateDashboardStreamPosition(User.GetUserId(), dto);
return Ok();
}
@ -146,6 +155,7 @@ public class StreamController : BaseApiController
[HttpPost("add-sidenav-stream")]
public async Task<ActionResult<SideNavStreamDto>> AddSideNav([FromQuery] int smartFilterId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
return Ok(await _streamService.CreateSideNavStreamFromSmartFilter(User.GetUserId(), smartFilterId));
}
@ -157,6 +167,7 @@ public class StreamController : BaseApiController
[HttpPost("add-sidenav-stream-from-external-source")]
public async Task<ActionResult<SideNavStreamDto>> AddSideNavFromExternalSource([FromQuery] int externalSourceId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
return Ok(await _streamService.CreateSideNavStreamFromExternalSource(User.GetUserId(), externalSourceId));
}
@ -168,6 +179,7 @@ public class StreamController : BaseApiController
[HttpPost("update-sidenav-stream")]
public async Task<ActionResult> UpdateSideNavStream(SideNavStreamDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
await _streamService.UpdateSideNavStream(User.GetUserId(), dto);
return Ok();
}
@ -180,6 +192,7 @@ public class StreamController : BaseApiController
[HttpPost("update-sidenav-position")]
public async Task<ActionResult> UpdateSideNavStreamPosition(UpdateStreamPositionDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto);
return Ok();
}
@ -187,7 +200,34 @@ public class StreamController : BaseApiController
[HttpPost("bulk-sidenav-stream-visibility")]
public async Task<ActionResult> BulkUpdateSideNavStream(BulkUpdateSideNavStreamVisibilityDto dto)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto);
return Ok();
}
/// <summary>
/// Removes a Smart Filter from a user's SideNav Streams
/// </summary>
/// <param name="sideNavStreamId"></param>
/// <returns></returns>
[HttpDelete("smart-filter-side-nav-stream")]
public async Task<ActionResult> DeleteSmartFilterSideNavStream([FromQuery] int sideNavStreamId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
await _streamService.DeleteSideNavSmartFilterStream(User.GetUserId(), sideNavStreamId);
return Ok();
}
/// <summary>
/// Removes a Smart Filter from a user's Dashboard Streams
/// </summary>
/// <param name="dashboardStreamId"></param>
/// <returns></returns>
[HttpDelete("smart-filter-dashboard-stream")]
public async Task<ActionResult> DeleteSmartFilterDashboardStream([FromQuery] int dashboardStreamId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
await _streamService.DeleteDashboardSmartFilterStream(User.GetUserId(), dashboardStreamId);
return Ok();
}
}

View file

@ -1,13 +1,21 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Theme;
using API.Entities;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
namespace API.Controllers;
@ -17,16 +25,19 @@ public class ThemeController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IThemeService _themeService;
private readonly ITaskScheduler _taskScheduler;
private readonly ILocalizationService _localizationService;
private readonly IDirectoryService _directoryService;
private readonly IMapper _mapper;
public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, ITaskScheduler taskScheduler,
ILocalizationService localizationService)
public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService,
ILocalizationService localizationService, IDirectoryService directoryService, IMapper mapper)
{
_unitOfWork = unitOfWork;
_themeService = themeService;
_taskScheduler = taskScheduler;
_localizationService = localizationService;
_directoryService = directoryService;
_mapper = mapper;
}
[ResponseCache(CacheProfileName = "10Minute")]
@ -37,13 +48,6 @@ public class ThemeController : BaseApiController
return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos());
}
[Authorize("RequireAdminRole")]
[HttpPost("scan")]
public ActionResult Scan()
{
_taskScheduler.ScanSiteThemes();
return Ok();
}
[Authorize("RequireAdminRole")]
[HttpPost("update-default")]
@ -78,4 +82,70 @@ public class ThemeController : BaseApiController
return BadRequest(await _localizationService.Get("en", ex.Message));
}
}
/// <summary>
/// Browse themes that can be used on this server
/// </summary>
/// <returns></returns>
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
[HttpGet("browse")]
public async Task<ActionResult<IEnumerable<DownloadableSiteThemeDto>>> BrowseThemes()
{
var themes = await _themeService.GetDownloadableThemes();
return Ok(themes.Where(t => !t.AlreadyDownloaded));
}
/// <summary>
/// Attempts to delete a theme. If already in use by users, will not allow
/// </summary>
/// <param name="themeId"></param>
/// <returns></returns>
[HttpDelete]
public async Task<ActionResult<IEnumerable<DownloadableSiteThemeDto>>> DeleteTheme(int themeId)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
await _themeService.DeleteTheme(themeId);
return Ok();
}
/// <summary>
/// Downloads a SiteTheme from upstream
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("download-theme")]
public async Task<ActionResult<SiteThemeDto>> DownloadTheme(DownloadableSiteThemeDto dto)
{
return Ok(_mapper.Map<SiteThemeDto>(await _themeService.DownloadRepoTheme(dto)));
}
/// <summary>
/// Uploads a new theme file
/// </summary>
/// <param name="formFile"></param>
/// <returns></returns>
[HttpPost("upload-theme")]
public async Task<ActionResult<SiteThemeDto>> DownloadTheme(IFormFile formFile)
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (!formFile.FileName.EndsWith(".css")) return BadRequest("Invalid file");
if (formFile.FileName.Contains("..")) return BadRequest("Invalid file");
var tempFile = await UploadToTemp(formFile);
// Set summary as "Uploaded by User.GetUsername() on DATE"
var theme = await _themeService.CreateThemeFromFile(tempFile, User.GetUsername());
return Ok(_mapper.Map<SiteThemeDto>(theme));
}
private async Task<string> UploadToTemp(IFormFile file)
{
var outputFile = Path.Join(_directoryService.TempDirectory, file.FileName);
await using var stream = System.IO.File.Create(outputFile);
await file.CopyToAsync(stream);
stream.Close();
return outputFile;
}
}

View file

@ -1,10 +1,14 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Uploads;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Tasks.Metadata;
using API.SignalR;
using Flurl.Http;
using Microsoft.AspNetCore.Authorization;
@ -28,11 +32,12 @@ public class UploadController : BaseApiController
private readonly IEventHub _eventHub;
private readonly IReadingListService _readingListService;
private readonly ILocalizationService _localizationService;
private readonly ICoverDbService _coverDbService;
/// <inheritdoc />
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger,
ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService,
ILocalizationService localizationService)
ILocalizationService localizationService, ICoverDbService coverDbService)
{
_unitOfWork = unitOfWork;
_imageService = imageService;
@ -42,6 +47,7 @@ public class UploadController : BaseApiController
_eventHub = eventHub;
_readingListService = readingListService;
_localizationService = localizationService;
_coverDbService = coverDbService;
}
/// <summary>
@ -90,26 +96,33 @@ public class UploadController : BaseApiController
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required"));
}
try
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist"));
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}");
if (!string.IsNullOrEmpty(filePath))
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist"));
var filePath = string.Empty;
var lockState = false;
if (!string.IsNullOrEmpty(uploadFileDto.Url))
{
series.CoverImage = filePath;
series.CoverImageLocked = true;
_unitOfWork.SeriesRepository.Update(series);
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}");
lockState = uploadFileDto.LockCover;
}
series.CoverImage = filePath;
series.CoverImageLocked = lockState;
_imageService.UpdateColorScape(series);
_unitOfWork.SeriesRepository.Update(series);
if (_unitOfWork.HasChanges())
{
// Refresh covers
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true);
}
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false);
await _unitOfWork.CommitAsync();
@ -138,24 +151,24 @@ public class UploadController : BaseApiController
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required"));
}
try
{
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id);
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadFileDto.Id);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");
if (!string.IsNullOrEmpty(filePath))
var filePath = string.Empty;
var lockState = false;
if (!string.IsNullOrEmpty(uploadFileDto.Url))
{
tag.CoverImage = filePath;
tag.CoverImageLocked = true;
_unitOfWork.CollectionTagRepository.Update(tag);
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");
lockState = uploadFileDto.LockCover;
}
tag.CoverImage = filePath;
tag.CoverImageLocked = lockState;
_imageService.UpdateColorScape(tag);
_unitOfWork.CollectionTagRepository.Update(tag);
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
@ -184,29 +197,31 @@ public class UploadController : BaseApiController
[HttpPost("reading-list")]
public async Task<ActionResult> UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto)
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// Check if Url is non-empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required"));
}
if (_readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null)
if (await _readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null)
return Unauthorized(await _localizationService.Translate(User.GetUserId(), "access-denied"));
try
{
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id);
if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist"));
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}");
if (!string.IsNullOrEmpty(filePath))
var filePath = string.Empty;
var lockState = false;
if (!string.IsNullOrEmpty(uploadFileDto.Url))
{
readingList.CoverImage = filePath;
readingList.CoverImageLocked = true;
_unitOfWork.ReadingListRepository.Update(readingList);
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}");
lockState = uploadFileDto.LockCover;
}
readingList.CoverImage = filePath;
readingList.CoverImageLocked = lockState;
_imageService.UpdateColorScape(readingList);
_unitOfWork.ReadingListRepository.Update(readingList);
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
@ -225,17 +240,14 @@ public class UploadController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-reading-list-save"));
}
private async Task<string> CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0)
private async Task<string> CreateThumbnail(UploadFileDto uploadFileDto, string filename)
{
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (thumbnailSize > 0)
{
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
filename, encodeFormat, thumbnailSize);
}
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var encodeFormat = settings.EncodeMediaAs;
var coverImageSize = settings.CoverImageSize;
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
filename, encodeFormat);
filename, encodeFormat, coverImageSize.GetDimensions().Width);
}
/// <summary>
@ -250,33 +262,42 @@ public class UploadController : BaseApiController
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required"));
}
try
{
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}");
if (!string.IsNullOrEmpty(filePath))
var filePath = string.Empty;
var lockState = false;
if (!string.IsNullOrEmpty(uploadFileDto.Url))
{
chapter.CoverImage = filePath;
chapter.CoverImageLocked = true;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId);
if (volume != null)
{
volume.CoverImage = chapter.CoverImage;
_unitOfWork.VolumeRepository.Update(volume);
}
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}");
lockState = uploadFileDto.LockCover;
}
chapter.CoverImage = filePath;
chapter.CoverImageLocked = lockState;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId);
if (volume != null)
{
volume.CoverImage = chapter.CoverImage;
volume.CoverImageLocked = lockState;
_unitOfWork.VolumeRepository.Update(volume);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
// Refresh covers
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId))!;
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true);
}
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false);
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
@ -294,6 +315,67 @@ public class UploadController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-chapter-save"));
}
/// <summary>
/// Replaces volume cover image and locks it with a base64 encoded image.
/// </summary>
/// <remarks>This will not update the underlying chapter</remarks>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("volume")]
public async Task<ActionResult> UploadVolumeCoverImageFromUrl(UploadFileDto uploadFileDto)
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
try
{
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(uploadFileDto.Id, VolumeIncludes.Chapters);
if (volume == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "volume-doesnt-exist"));
var filePath = string.Empty;
var lockState = false;
if (!string.IsNullOrEmpty(uploadFileDto.Url))
{
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetVolumeFormat(uploadFileDto.Id)}");
lockState = uploadFileDto.LockCover;
}
volume.CoverImage = filePath;
volume.CoverImageLocked = lockState;
_imageService.UpdateColorScape(volume);
_unitOfWork.VolumeRepository.Update(volume);
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
// Refresh covers
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!;
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true);
}
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(uploadFileDto.Id, MessageFactoryEntityTypes.Volume), false);
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Chapter), false);
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Volume {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-volume-save"));
}
/// <summary>
/// Replaces library cover image with a base64 encoded image. If empty string passed, will reset to null.
/// </summary>
@ -312,6 +394,7 @@ public class UploadController : BaseApiController
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
library.CoverImage = null;
library.ResetColorScape();
_unitOfWork.LibraryRepository.Update(library);
if (_unitOfWork.HasChanges())
{
@ -326,12 +409,12 @@ public class UploadController : BaseApiController
try
{
var filePath = await CreateThumbnail(uploadFileDto,
$"{ImageService.GetLibraryFormat(uploadFileDto.Id)}",
ImageService.LibraryThumbnailWidth);
$"{ImageService.GetLibraryFormat(uploadFileDto.Id)}");
if (!string.IsNullOrEmpty(filePath))
{
library.CoverImage = filePath;
_imageService.UpdateColorScape(library);
_unitOfWork.LibraryRepository.Update(library);
}
@ -360,6 +443,7 @@ public class UploadController : BaseApiController
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("reset-chapter-lock")]
[Obsolete("Use LockCover in UploadFileDto")]
public async Task<ActionResult> ResetChapterLock(UploadFileDto uploadFileDto)
{
try
@ -367,12 +451,15 @@ public class UploadController : BaseApiController
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var originalFile = chapter.CoverImage;
chapter.CoverImage = string.Empty;
chapter.CoverImageLocked = false;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!;
volume.CoverImage = chapter.CoverImage;
_unitOfWork.VolumeRepository.Update(volume);
var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!;
if (_unitOfWork.HasChanges())
@ -393,4 +480,32 @@ public class UploadController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock"));
}
/// <summary>
/// Replaces person tag cover image and locks it with a base64 encoded image
/// </summary>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("person")]
public async Task<ActionResult> UploadPersonCoverImageFromUrl(UploadFileDto uploadFileDto)
{
try
{
var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id);
if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, true);
return Ok();
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Person {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-person-save"));
}
}

View file

@ -1,11 +1,14 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.KavitaPlus.Account;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
@ -22,14 +25,16 @@ public class UsersController : BaseApiController
private readonly IMapper _mapper;
private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService;
private readonly ILicenseService _licenseService;
public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub,
ILocalizationService localizationService)
ILocalizationService localizationService, ILicenseService licenseService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_eventHub = eventHub;
_localizationService = localizationService;
_licenseService = licenseService;
}
[Authorize(Policy = "RequireAdminRole")]
@ -82,12 +87,20 @@ public class UsersController : BaseApiController
return Ok(libs.Any(x => x.Id == libraryId));
}
/// <summary>
/// Update the user preferences
/// </summary>
/// <remarks>If the user has ReadOnly role, they will not be able to perform this action</remarks>
/// <param name="preferencesDto"></param>
/// <returns></returns>
[HttpPost("update-preferences")]
public async Task<ActionResult<UserPreferencesDto>> UpdatePreferences(UserPreferencesDto preferencesDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
AppUserIncludes.UserPreferences);
if (user == null) return Unauthorized();
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var existingPreferences = user!.UserPreferences;
existingPreferences.ReadingDirection = preferencesDto.ReadingDirection;
@ -112,17 +125,37 @@ public class UsersController : BaseApiController
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries;
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
if (_localizationService.GetLocales().Contains(preferencesDto.Locale))
existingPreferences.PdfTheme = preferencesDto.PdfTheme;
existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
if (await _licenseService.HasActiveLicense())
{
existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled;
existingPreferences.WantToReadSync = preferencesDto.WantToReadSync;
}
if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id)
{
var theme = await _unitOfWork.SiteThemeRepository.GetTheme(preferencesDto.Theme!.Id);
existingPreferences.Theme = theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
}
if (_localizationService.GetLocales().Select(l => l.FileName).Contains(preferencesDto.Locale))
{
existingPreferences.Locale = preferencesDto.Locale;
}
_unitOfWork.UserRepository.Update(existingPreferences);
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-pref"));
@ -153,4 +186,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,84 @@
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Extensions;
using API.Services;
using API.SignalR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
#nullable enable
public class VolumeController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
public VolumeController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_eventHub = eventHub;
}
/// <summary>
/// Returns the appropriate Volume
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet]
public async Task<ActionResult<VolumeDto?>> GetVolume(int volumeId)
{
return Ok(await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId()));
}
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete]
public async Task<ActionResult<bool>> DeleteVolume(int volumeId)
{
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId,
VolumeIncludes.Chapters | VolumeIncludes.People | VolumeIncludes.Tags);
if (volume == null)
return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist"));
_unitOfWork.VolumeRepository.Remove(volume);
if (await _unitOfWork.CommitAsync())
{
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false);
return Ok(true);
}
return Ok(false);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("multiple")]
public async Task<ActionResult<bool>> DeleteMultipleVolumes(int[] volumesIds)
{
var volumes = await _unitOfWork.VolumeRepository.GetVolumesById(volumesIds);
if (volumes.Count != volumesIds.Length)
{
return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist"));
}
_unitOfWork.VolumeRepository.Remove(volumes);
if (!await _unitOfWork.CommitAsync())
{
return Ok(false);
}
foreach (var volume in volumes)
{
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false);
}
return Ok(true);
}
}

View file

@ -40,6 +40,7 @@ public class WantToReadController : BaseApiController
/// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)
/// </summary>
/// <remarks>This will be removed in v0.8.x</remarks>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>

View file

@ -9,7 +9,7 @@ public class ConfirmEmailDto
[Required]
public string Token { get; set; } = default!;
[Required]
[StringLength(32, MinimumLength = 6)]
[StringLength(256, MinimumLength = 6)]
public string Password { get; set; } = default!;
[Required]
public string Username { get; set; } = default!;

View file

@ -9,6 +9,6 @@ public class ConfirmPasswordResetDto
[Required]
public string Token { get; set; } = default!;
[Required]
[StringLength(32, MinimumLength = 6)]
[StringLength(256, MinimumLength = 6)]
public string Password { get; set; } = default!;
}

View file

@ -13,7 +13,7 @@ public class ResetPasswordDto
/// The new password
/// </summary>
[Required]
[StringLength(32, MinimumLength = 6)]
[StringLength(256, MinimumLength = 6)]
public string Password { get; init; } = default!;
/// <summary>
/// The old, existing password. If an admin is performing the change, this is not required. Otherwise, it is.

View file

@ -2,6 +2,7 @@
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Account;
#nullable enable
public record UpdateUserDto
{
@ -18,4 +19,8 @@ public record UpdateUserDto
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
/// </summary>
public AgeRestrictionDto AgeRestriction { get; init; } = default!;
/// <summary>
/// Email of the user
/// </summary>
public string? Email { get; set; } = default!;
}

12
API/DTOs/BulkActionDto.cs Normal file
View file

@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace API.DTOs;
public class BulkActionDto
{
public List<int> Ids { get; set; }
/**
* If this is a Scan action, will ignore optimizations
*/
public bool? Force { get; set; }
}

View file

@ -1,26 +1,39 @@
using System;
using System.Collections.Generic;
using API.DTOs.Metadata;
using API.Entities.Enums;
using API.Entities.Interfaces;
namespace API.DTOs;
#nullable enable
/// <summary>
/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
/// file (abstracted from type).
/// </summary>
public class ChapterDto : IHasReadTimeEstimate
public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
{
public int Id { get; init; }
/// <summary>
/// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2".
/// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If special, will be special name.
/// </summary>
/// <remarks>This can be something like 19.HU or Alpha as some comics are like this</remarks>
public string Range { get; init; } = default!;
/// <summary>
/// Smallest number of the Range.
/// </summary>
[Obsolete("Use MinNumber and MaxNumber instead")]
public string Number { get; init; } = default!;
/// <summary>
/// This may be 0 under the circumstance that the Issue is "Alpha" or other non-standard numbers.
/// </summary>
public float MinNumber { get; init; }
public float MaxNumber { get; init; }
/// <summary>
/// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden.
/// </summary>
public float SortOrder { get; set; }
/// <summary>
/// Total number of pages in all MangaFiles
/// </summary>
public int Pages { get; init; }
@ -99,7 +112,7 @@ public class ChapterDto : IHasReadTimeEstimate
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
public float AvgHoursToRead { get; set; }
/// <summary>
/// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary>
@ -109,4 +122,79 @@ public class ChapterDto : IHasReadTimeEstimate
/// </summary>
/// <remarks>This is guaranteed to be Valid</remarks>
public string ISBN { get; set; }
#region Metadata
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> CoverArtists { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Publishers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Characters { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Pencillers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Inkers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Imprints { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Colorists { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Teams { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Locations { get; set; } = new List<PersonDto>();
public ICollection<GenreTagDto> Genres { get; set; } = new List<GenreTagDto>();
/// <summary>
/// Collection of all Tags from underlying chapters for a Series
/// </summary>
public ICollection<TagDto> Tags { get; set; } = new List<TagDto>();
public PublicationStatus PublicationStatus { get; set; }
/// <summary>
/// Language for the Chapter/Issue
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Number in the TotalCount of issues
/// </summary>
public int Count { get; set; }
/// <summary>
/// Total number of issues for the series
/// </summary>
public int TotalCount { get; set; }
public bool LanguageLocked { get; set; }
public bool SummaryLocked { get; set; }
/// <summary>
/// Locked by user so metadata updates from scan loop will not override AgeRating
/// </summary>
public bool AgeRatingLocked { get; set; }
/// <summary>
/// Locked by user so metadata updates from scan loop will not override PublicationStatus
/// </summary>
public bool PublicationStatusLocked { get; set; }
public bool GenresLocked { get; set; }
public bool TagsLocked { get; set; }
public bool WriterLocked { get; set; }
public bool CharacterLocked { get; set; }
public bool ColoristLocked { get; set; }
public bool EditorLocked { get; set; }
public bool InkerLocked { get; set; }
public bool ImprintLocked { get; set; }
public bool LettererLocked { get; set; }
public bool PencillerLocked { get; set; }
public bool PublisherLocked { get; set; }
public bool TranslatorLocked { get; set; }
public bool TeamLocked { get; set; }
public bool LocationLocked { get; set; }
public bool CoverArtistLocked { get; set; }
public bool ReleaseYearLocked { get; set; }
#endregion
public string CoverImage { get; set; }
public string PrimaryColor { get; set; } = string.Empty;
public string SecondaryColor { get; set; } = string.Empty;
public void ResetColorScape()
{
PrimaryColor = string.Empty;
SecondaryColor = string.Empty;
}
}

View file

@ -0,0 +1,61 @@
using System;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Services.Plus;
namespace API.DTOs.Collection;
#nullable enable
public class AppUserCollectionDto : IHasCoverImage
{
public int Id { get; init; }
public string Title { get; set; } = default!;
public string? Summary { get; set; } = default!;
public bool Promoted { get; set; }
public AgeRating AgeRating { get; set; }
/// <summary>
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
/// </summary>
public string? CoverImage { get; set; } = string.Empty;
public string PrimaryColor { get; set; } = string.Empty;
public string SecondaryColor { get; set; } = string.Empty;
public bool CoverImageLocked { get; set; }
/// <summary>
/// Number of Series in the Collection
/// </summary>
public int ItemCount { get; set; }
/// <summary>
/// Owner of the Collection
/// </summary>
public string? Owner { get; set; }
/// <summary>
/// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
/// </summary>
public DateTime LastSyncUtc { get; set; }
/// <summary>
/// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote
/// </summary>
public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita;
/// <summary>
/// For Non-Kavita sourced collections, the url to sync from
/// </summary>
public string? SourceUrl { get; set; }
/// <summary>
/// Total number of items as of the last sync. Not applicable for Kavita managed collections.
/// </summary>
public int TotalSourceCount { get; set; }
/// <summary>
/// A <br/> separated string of all missing series
/// </summary>
public string? MissingSeriesFromSource { get; set; }
public void ResetColorScape()
{
PrimaryColor = string.Empty;
SecondaryColor = string.Empty;
}
}

View file

@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Collection;
public class DeleteCollectionsDto
{
[Required]
public IList<int> CollectionIds { get; set; }
}

View file

@ -0,0 +1,20 @@
namespace API.DTOs.Collection;
#nullable enable
/// <summary>
/// Represents an Interest Stack from MAL
/// </summary>
public class MalStackDto
{
public required string Title { get; set; }
public required long StackId { get; set; }
public required string Url { get; set; }
public required string? Author { get; set; }
public required int SeriesCount { get; set; }
public required int RestackCount { get; set; }
/// <summary>
/// If an existing collection exists within Kavita
/// </summary>
/// <remarks>This is filled out from Kavita and not Kavita+</remarks>
public int ExistingId { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.Collection;
public class PromoteCollectionsDto
{
public IList<int> CollectionIds { get; init; }
public bool Promoted { get; init; }
}

View file

@ -1,5 +1,8 @@
namespace API.DTOs.CollectionTags;
using System;
namespace API.DTOs.CollectionTags;
[Obsolete("Use AppUserCollectionDto")]
public class CollectionTagDto
{
public int Id { get; set; }

View file

@ -1,9 +1,11 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using API.DTOs.Collection;
namespace API.DTOs.CollectionTags;
public class UpdateSeriesForTagDto
{
public CollectionTagDto Tag { get; init; } = default!;
public AppUserCollectionDto Tag { get; init; } = default!;
public IEnumerable<int> SeriesIdsToRemove { get; init; } = default!;
}

11
API/DTOs/ColorScape.cs Normal file
View file

@ -0,0 +1,11 @@
namespace API.DTOs;
#nullable enable
/// <summary>
/// A primary and secondary color
/// </summary>
public class ColorScape
{
public required string? Primary { get; set; }
public required string? Secondary { get; set; }
}

View file

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace API.DTOs;
public class CopySettingsFromLibraryDto
{
public int SourceLibraryId { get; set; }
public List<int> TargetLibraryIds { get; set; }
/// <summary>
/// Include copying over the type
/// </summary>
public bool IncludeType { get; set; }
}

View file

@ -0,0 +1,16 @@
using System.Collections.Generic;
using YamlDotNet.Serialization;
namespace API.DTOs.CoverDb;
public class CoverDbAuthor
{
[YamlMember(Alias = "name", ApplyNamingConventions = false)]
public string Name { get; set; }
[YamlMember(Alias = "aliases", ApplyNamingConventions = false)]
public List<string> Aliases { get; set; } = new List<string>();
[YamlMember(Alias = "ids", ApplyNamingConventions = false)]
public CoverDbPersonIds Ids { get; set; }
[YamlMember(Alias = "image_path", ApplyNamingConventions = false)]
public string ImagePath { get; set; }
}

View file

@ -0,0 +1,10 @@
using System.Collections.Generic;
using YamlDotNet.Serialization;
namespace API.DTOs.CoverDb;
public class CoverDbPeople
{
[YamlMember(Alias = "people", ApplyNamingConventions = false)]
public List<CoverDbAuthor> People { get; set; } = new List<CoverDbAuthor>();
}

View file

@ -0,0 +1,20 @@
using YamlDotNet.Serialization;
namespace API.DTOs.CoverDb;
#nullable enable
public class CoverDbPersonIds
{
[YamlMember(Alias = "hardcover_id", ApplyNamingConventions = false)]
public string? HardcoverId { get; set; } = null;
[YamlMember(Alias = "amazon_id", ApplyNamingConventions = false)]
public string? AmazonId { get; set; } = null;
[YamlMember(Alias = "metron_id", ApplyNamingConventions = false)]
public string? MetronId { get; set; } = null;
[YamlMember(Alias = "comicvine_id", ApplyNamingConventions = false)]
public string? ComicVineId { get; set; } = null;
[YamlMember(Alias = "anilist_id", ApplyNamingConventions = false)]
public string? AnilistId { get; set; } = null;
[YamlMember(Alias = "mal_id", ApplyNamingConventions = false)]
public string? MALId { get; set; } = null;
}

View file

@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace API.DTOs;
public class DeleteChaptersDto
{
public IList<int> ChapterIds { get; set; } = default!;
}

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

@ -33,5 +33,9 @@ public enum SortField
/// <summary>
/// Kavita+ Only - External Average Rating
/// </summary>
AverageRating = 8
AverageRating = 8,
/// <summary>
/// Randomise the order
/// </summary>
Random = 9
}

View file

@ -53,4 +53,8 @@ public enum FilterComparison
/// Is Date not between now and X seconds ago
/// </summary>
IsNotInLast = 15,
/// <summary>
/// There are no records
/// </summary>
IsEmpty = 16
}

View file

@ -48,6 +48,13 @@ public enum FilterField
/// <summary>
/// Average rating from Kavita+ - Not usable for non-licensed users
/// </summary>
AverageRating = 28
AverageRating = 28,
Imprint = 29,
Team = 30,
Location = 31,
/// <summary>
/// Last time User Read
/// </summary>
ReadLast = 32,
}

10
API/DTOs/KavitaLocale.cs Normal file
View file

@ -0,0 +1,10 @@
namespace API.DTOs;
public class KavitaLocale
{
public string FileName { get; set; } // Key
public string RenderName { get; set; }
public float TranslationCompletion { get; set; }
public bool IsRtL { get; set; }
public string Hash { get; set; } // ETAG hash so I can run my own localization busting implementation
}

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,17 @@
using API.DTOs.Scrobbling;
namespace API.DTOs.KavitaPlus.ExternalMetadata;
#nullable enable
/// <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,17 @@
using System.Collections.Generic;
using API.DTOs.Scrobbling;
namespace API.DTOs.KavitaPlus.ExternalMetadata;
#nullable enable
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,17 @@
using System.Collections.Generic;
using API.DTOs.Recommendation;
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 ExternalSeriesDetailDto? Series { get; set; }
public int? AniListId { get; set; }
public long? MalId { get; set; }
public int? CbrId { get; set; }
}

View file

@ -1,4 +1,5 @@
namespace API.DTOs.License;
namespace API.DTOs.KavitaPlus.License;
#nullable enable
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,5 @@
namespace API.DTOs.License;
namespace API.DTOs.KavitaPlus.License;
#nullable enable
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,36 @@
using System;
using System.Collections.Generic;
using API.DTOs.SeriesDetail;
namespace API.DTOs.KavitaPlus.Metadata;
/// <summary>
/// Information about an individual issue/chapter/book from Kavita+
/// </summary>
public class ExternalChapterDto
{
public string Title { get; set; }
public string IssueNumber { get; set; }
public decimal? CriticRating { get; set; }
public decimal? UserRating { get; set; }
public string? Summary { get; set; }
public IList<string>? Writers { get; set; }
public IList<string>? Artists { get; set; }
public DateTime? ReleaseDate { get; set; }
public string? Publisher { get; set; }
public string? CoverImageUrl { get; set; }
public string? IssueUrl { get; set; }
public IList<UserReviewDto> CriticReviews { get; set; }
public IList<UserReviewDto> UserReviews { get; set; }
}

View file

@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Scrobbling;
using API.Services.Plus;
namespace API.DTOs.Recommendation;
#nullable enable
/// <summary>
/// This is AniListSeries
/// </summary>
public class ExternalSeriesDetailDto
{
public string Name { get; set; }
public int? AniListId { get; set; }
public long? MALId { get; set; }
public int? CbrId { get; set; }
public IList<string> Synonyms { get; set; } = [];
public PlusMediaFormat PlusMediaFormat { get; set; }
public string? SiteUrl { get; set; }
public string? CoverUrl { get; set; }
public IList<string> Genres { get; set; }
public IList<SeriesStaffDto> Staff { get; set; }
public IList<MetadataTagDto> Tags { get; set; }
public string? Summary { get; set; }
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList;
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public int AverageScore { get; set; }
public int Chapters { get; set; }
public int Volumes { get; set; }
public IList<SeriesRelationship>? Relations { get; set; } = [];
public IList<SeriesCharacter>? Characters { get; set; } = [];
#region Comic Only
public string? Publisher { get; set; }
/// <summary>
/// Only from CBR for <see cref="ScrobbleProvider.Cbr"/>. Full metadata about issues
/// </summary>
public IList<ExternalChapterDto>? ChapterDtos { get; set; }
#endregion
}

View file

@ -0,0 +1,22 @@
using API.Entities.Enums;
namespace API.DTOs.KavitaPlus.Metadata;
public class MetadataFieldMappingDto
{
public int Id { get; set; }
public MetadataFieldType SourceType { get; set; }
public MetadataFieldType DestinationType { get; set; }
/// <summary>
/// The string in the source
/// </summary>
public string SourceValue { get; set; }
/// <summary>
/// Write the string as this in the Destination (can also just be the Source)
/// </summary>
public string DestinationValue { get; set; }
/// <summary>
/// If true, the tag will be Moved over vs Copied over
/// </summary>
public bool ExcludeFromSource { get; set; }
}

View file

@ -0,0 +1,125 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
using API.Entities.MetadataMatching;
using NotImplementedException = System.NotImplementedException;
namespace API.DTOs.KavitaPlus.Metadata;
public class MetadataSettingsDto
{
/// <summary>
/// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Allow the Summary to be written
/// </summary>
public bool EnableSummary { get; set; }
/// <summary>
/// Allow Publication status to be derived and updated
/// </summary>
public bool EnablePublicationStatus { get; set; }
/// <summary>
/// Allow Relationships between series to be set
/// </summary>
public bool EnableRelationships { get; set; }
/// <summary>
/// Allow People to be created (including downloading images)
/// </summary>
public bool EnablePeople { get; set; }
/// <summary>
/// Allow Start date to be set within the Series
/// </summary>
public bool EnableStartDate { get; set; }
/// <summary>
/// Allow setting the Localized name
/// </summary>
public bool EnableLocalizedName { get; set; }
/// <summary>
/// Allow setting the cover image
/// </summary>
public bool EnableCoverImage { get; set; }
#region Chapter Metadata
/// <summary>
/// Allow Summary to be set within Chapter/Issue
/// </summary>
public bool EnableChapterSummary { get; set; }
/// <summary>
/// Allow Release Date to be set within Chapter/Issue
/// </summary>
public bool EnableChapterReleaseDate { get; set; }
/// <summary>
/// Allow Title to be set within Chapter/Issue
/// </summary>
public bool EnableChapterTitle { get; set; }
/// <summary>
/// Allow Publisher to be set within Chapter/Issue
/// </summary>
public bool EnableChapterPublisher { get; set; }
/// <summary>
/// Allow setting the cover image for the Chapter/Issue
/// </summary>
public bool EnableChapterCoverImage { get; set; }
#endregion
// Need to handle the Genre/tags stuff
public bool EnableGenres { get; set; } = true;
public bool EnableTags { get; set; } = true;
/// <summary>
/// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names.
/// </summary>
public bool FirstLastPeopleNaming { get; set; }
/// <summary>
/// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.
/// </summary>
public Dictionary<string, AgeRating> AgeRatingMappings { get; set; }
/// <summary>
/// A list of rules that allow mapping a genre/tag to another genre/tag
/// </summary>
public List<MetadataFieldMappingDto> FieldMappings { get; set; }
/// <summary>
/// A list of overrides that will enable writing to locked fields
/// </summary>
public List<MetadataSettingField> Overrides { get; set; }
/// <summary>
/// Do not allow any Genre/Tag in this list to be written to Kavita
/// </summary>
public List<string> Blacklist { get; set; }
/// <summary>
/// Only allow these Tags to be written to Kavita
/// </summary>
public List<string> Whitelist { get; set; }
/// <summary>
/// Which Roles to allow metadata downloading for
/// </summary>
public List<PersonRole> PersonRoles { get; set; }
/// <summary>
/// Override list contains this field
/// </summary>
/// <param name="field"></param>
/// <returns></returns>
public bool HasOverride(MetadataSettingField field)
{
return Overrides.Contains(field);
}
/// <summary>
/// If this Person role is allowed to be written
/// </summary>
/// <param name="character"></param>
/// <returns></returns>
public bool IsPersonAllowed(PersonRole character)
{
return PersonRoles.Contains(character);
}
}

View file

@ -0,0 +1,19 @@
namespace API.DTOs.KavitaPlus.Metadata;
#nullable enable
public enum CharacterRole
{
Main = 0,
Supporting = 1,
Background = 2
}
public class SeriesCharacter
{
public string Name { get; set; }
public required string Description { get; set; }
public required string Url { get; set; }
public string? ImageUrl { get; set; }
public CharacterRole Role { get; set; }
}

View file

@ -0,0 +1,24 @@
using API.DTOs.Scrobbling;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Services.Plus;
namespace API.DTOs.KavitaPlus.Metadata;
public class ALMediaTitle
{
public string? EnglishTitle { get; set; }
public string RomajiTitle { get; set; }
public string NativeTitle { get; set; }
public string PreferredTitle { get; set; }
}
public class SeriesRelationship
{
public int AniListId { get; set; }
public int? MalId { get; set; }
public ALMediaTitle SeriesName { get; set; }
public RelationKind Relation { get; set; }
public ScrobbleProvider Provider { get; set; }
public PlusMediaFormat PlusMediaFormat { get; set; } = PlusMediaFormat.Manga;
}

View file

@ -61,4 +61,10 @@ public class LibraryDto
/// A set of globs that will exclude matching content from being scanned
/// </summary>
public ICollection<string> ExcludePatterns { get; set; }
/// <summary>
/// Allow any series within this Library to download metadata.
/// </summary>
/// <remarks>This does not exclude the library from being linked to wrt Series Relationships</remarks>
/// <remarks>Requires a valid LicenseKey</remarks>
public bool AllowMetadataMatching { get; set; } = true;
}

View file

@ -2,14 +2,28 @@
using API.Entities.Enums;
namespace API.DTOs;
#nullable enable
public class MangaFileDto
{
public int Id { get; init; }
/// <summary>
/// Absolute path to the archive file (normalized)
/// </summary>
public string FilePath { get; init; } = default!;
/// <summary>
/// Number of pages for the given file
/// </summary>
public int Pages { get; init; }
/// <summary>
/// How many bytes make up this file
/// </summary>
public long Bytes { get; init; }
public MangaFormat Format { get; init; }
public DateTime Created { get; init; }
/// <summary>
/// File extension
/// </summary>
public string? Extension { get; set; }
}

View file

@ -20,4 +20,6 @@ public class MediaErrorDto
/// Exception message
/// </summary>
public string Details { get; set; }
public DateTime CreatedUtc { get; set; }
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using API.Entities.Enums;
namespace API.DTOs.Metadata;
@ -7,6 +8,7 @@ namespace API.DTOs.Metadata;
/// <summary>
/// Exclusively metadata about a given chapter
/// </summary>
[Obsolete("Will not be maintained as of v0.8.1")]
public class ChapterMetadataDto
{
public int Id { get; set; }
@ -18,10 +20,13 @@ public class ChapterMetadataDto
public ICollection<PersonDto> Characters { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Pencillers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Inkers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Imprints { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Colorists { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Teams { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Locations { get; set; } = new List<PersonDto>();
public ICollection<GenreTagDto> Genres { get; set; } = new List<GenreTagDto>();

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

@ -0,0 +1,16 @@
namespace API.DTOs;
/// <summary>
/// Used to browse writers and click in to see their series
/// </summary>
public class BrowsePersonDto : PersonDto
{
/// <summary>
/// Number of Series this Person is the Writer for
/// </summary>
public int SeriesCount { get; set; }
/// <summary>
/// Number or Issues this Person is the Writer for
/// </summary>
public int IssueCount { get; set; }
}

View file

@ -0,0 +1,40 @@
using System.Runtime.Serialization;
namespace API.DTOs;
#nullable enable
public class PersonDto
{
public int Id { get; set; }
public required string Name { get; set; }
public bool CoverImageLocked { get; set; }
public string? PrimaryColor { get; set; }
public string? SecondaryColor { get; set; }
public string? CoverImage { get; set; }
public string? Description { get; set; }
/// <summary>
/// ASIN for person
/// </summary>
/// <remarks>Can be used for Amazon author lookup</remarks>
public string? Asin { get; set; }
/// <summary>
/// https://anilist.co/staff/{AniListId}/
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public int AniListId { get; set; } = 0;
/// <summary>
/// https://myanimelist.net/people/{MalId}/
/// https://myanimelist.net/character/{MalId}/CharacterName
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public long MalId { get; set; } = 0;
/// <summary>
/// https://hardcover.app/authors/{HardcoverId}
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public string? HardcoverId { get; set; }
}

View file

@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace API.DTOs;
#nullable enable
public class UpdatePersonDto
{
[Required]
public int Id { get; init; }
[Required]
public bool CoverImageLocked { get; set; }
[Required]
public string Name {get; set;}
public string? Description { get; set; }
public int? AniListId { get; set; }
public long? MalId { get; set; }
public string? HardcoverId { get; set; }
public string? Asin { get; set; }
}

View file

@ -1,10 +0,0 @@
using API.Entities.Enums;
namespace API.DTOs;
public class PersonDto
{
public int Id { get; set; }
public required string Name { get; set; }
public PersonRole Role { get; set; }
}

View file

@ -0,0 +1,19 @@
using System;
namespace API.DTOs.Progress;
/// <summary>
/// A full progress Record from the DB (not all data, only what's needed for API)
/// </summary>
public class FullProgressDto
{
public int Id { get; set; }
public int ChapterId { get; set; }
public int PagesRead { get; set; }
public DateTime LastModified { get; set; }
public DateTime LastModifiedUtc { get; set; }
public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; }
public int AppUserId { get; set; }
public string UserName { get; set; }
}

View file

@ -1,7 +1,7 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs;
namespace API.DTOs.Progress;
#nullable enable
public class ProgressDto

View file

@ -1,4 +1,5 @@
namespace API.DTOs.Reader;
#nullable enable
public class CreatePersonalToCDto
{

View file

@ -16,5 +16,5 @@ public record HourEstimateRangeDto
/// <summary>
/// Estimated average hours to read the selection
/// </summary>
public int AvgHours { get; init; } = 1;
public float AvgHours { get; init; } = 1f;
}

View file

@ -1,4 +1,5 @@
using System.Xml.Serialization;
using API.Data.Metadata;
namespace API.DTOs.ReadingLists.CBL;
@ -21,6 +22,12 @@ public class CblBook
[XmlAttribute("Year")]
public string Year { get; set; }
/// <summary>
/// Main Series, Annual, Limited Series
/// </summary>
/// <remarks>This maps to <see cref="ComicInfo">Format</see> tag</remarks>
[XmlAttribute("Format")]
public string Format { get; set; }
/// <summary>
/// The underlying filetype
/// </summary>
/// <remarks>This is not part of the standard and explicitly for Kavita to support non cbz/cbr files</remarks>

View file

@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.ReadingLists;
public class DeleteReadingListsDto
{
[Required]
public IList<int> ReadingListIds { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.ReadingLists;
public class PromoteReadingListsDto
{
public IList<int> ReadingListIds { get; init; }
public bool Promoted { get; init; }
}

View file

@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace API.DTOs.ReadingLists;
public class ReadingListCast
{
public ICollection<PersonDto> Writers { get; set; } = [];
public ICollection<PersonDto> CoverArtists { get; set; } = [];
public ICollection<PersonDto> Publishers { get; set; } = [];
public ICollection<PersonDto> Characters { get; set; } = [];
public ICollection<PersonDto> Pencillers { get; set; } = [];
public ICollection<PersonDto> Inkers { get; set; } = [];
public ICollection<PersonDto> Imprints { get; set; } = [];
public ICollection<PersonDto> Colorists { get; set; } = [];
public ICollection<PersonDto> Letterers { get; set; } = [];
public ICollection<PersonDto> Editors { get; set; } = [];
public ICollection<PersonDto> Translators { get; set; } = [];
public ICollection<PersonDto> Teams { get; set; } = [];
public ICollection<PersonDto> Locations { get; set; } = [];
}

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