Merged develop in
This commit is contained in:
commit
d12a79892f
1443 changed files with 215765 additions and 44113 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
{
|
||||
|
|
|
|||
396
API/Controllers/ChapterController.cs
Normal file
396
API/Controllers/ChapterController.cs
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
API/Controllers/ColorScapeController.cs
Normal file
63
API/Controllers/ColorScapeController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
26
API/Controllers/EmailController.cs
Normal file
26
API/Controllers/EmailController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
API/Controllers/ManageController.cs
Normal file
40
API/Controllers/ManageController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
177
API/Controllers/PersonController.cs
Normal file
177
API/Controllers/PersonController.cs
Normal 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));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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!,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
API/Controllers/VolumeController.cs
Normal file
84
API/Controllers/VolumeController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue