Cleanup from the Release (#2127)

* Added an FAQ link on the Kavita+ tab.

* Don't query Kavita+ for ratings on comic libraries as there is no upstream provider yet.

* Jumpbar keys are a little hard to click

* Fixed an issue where libraries that don't allow scrobbling could be scrobbled when generating past history with read events.

* Made the min/max release year on metadata filter number and removed the spin arrows for styling.

* Fixed disable tabs color contrast due to bootstrap undocumented change.

* Refactored whole codebase to unify caching mechanism. Upped the default cache memory amount to 75 to account for the extra data load. Still LRU.

Fixed an issue where Cache key was using Port instead.

Refactored all the Configuration code to use strongly typed deserialization.

* Fixed an issue where get latest progress would throw an exception if there was no progress due to LINQ and MAX query.

* Fixed a bug where Send to Device wasn't present on Series cards.

* Hooked up the ability to change the cache size for Kavita via the UI.
This commit is contained in:
Joe Milazzo 2023-07-12 16:06:30 -05:00 committed by GitHub
parent 1ed8889d08
commit 81da9dc444
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 402 additions and 272 deletions

View File

@ -11,4 +11,11 @@ public static class EasyCacheProfiles
/// If a user's license is valid /// If a user's license is valid
/// </summary> /// </summary>
public const string License = "license"; public const string License = "license";
/// <summary>
/// Cache the libraries on the server
/// </summary>
public const string Library = "library";
public const string KavitaPlusReviews = "kavita+reviews";
public const string KavitaPlusRecommendations = "kavita+recommendations";
public const string KavitaPlusRatings = "kavita+ratings";
} }

View File

@ -88,7 +88,8 @@ public class DeviceController : BaseApiController
return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own."); return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own.");
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "started"), userId); await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "started"), userId);
try try
{ {
var success = await _deviceService.SendTo(dto.ChapterIds, dto.DeviceId); var success = await _deviceService.SendTo(dto.ChapterIds, dto.DeviceId);
@ -100,7 +101,8 @@ public class DeviceController : BaseApiController
} }
finally finally
{ {
await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "ended"), userId); await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice,
MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "ended"), userId);
} }
return BadRequest("There was an error sending the file to the device"); return BadRequest("There was an error sending the file to the device");
@ -108,6 +110,42 @@ public class DeviceController : BaseApiController
[HttpPost("send-series-to")]
public async Task<ActionResult> SendSeriesToDevice(SendSeriesToDeviceDto dto)
{
if (dto.SeriesId <= 0) return BadRequest("SeriesId must be greater than 0");
if (dto.DeviceId < 0) return BadRequest("DeviceId must be greater than 0");
if (await _emailService.IsDefaultEmailService())
return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own.");
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "started"), userId);
var series =
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
SeriesIncludes.Volumes | SeriesIncludes.Chapters);
if (series == null) return BadRequest("Series doesn't Exist");
var chapterIds = series.Volumes.SelectMany(v => v.Chapters.Select(c => c.Id)).ToList();
try
{
var success = await _deviceService.SendTo(chapterIds, dto.DeviceId);
if (success) return Ok();
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
finally
{
await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "ended"), userId);
}
return BadRequest("There was an error sending the file(s) to the device");
}
} }

View File

@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
@ -18,11 +18,10 @@ using API.Services;
using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner;
using API.SignalR; using API.SignalR;
using AutoMapper; using AutoMapper;
using EasyCaching.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using TaskScheduler = API.Services.TaskScheduler; using TaskScheduler = API.Services.TaskScheduler;
namespace API.Controllers; namespace API.Controllers;
@ -37,12 +36,13 @@ public class LibraryController : BaseApiController
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub; private readonly IEventHub _eventHub;
private readonly ILibraryWatcher _libraryWatcher; private readonly ILibraryWatcher _libraryWatcher;
private readonly IMemoryCache _memoryCache; private readonly IEasyCachingProvider _libraryCacheProvider;
private const string CacheKey = "library_"; private const string CacheKey = "library_";
public LibraryController(IDirectoryService directoryService, public LibraryController(IDirectoryService directoryService,
ILogger<LibraryController> logger, IMapper mapper, ITaskScheduler taskScheduler, ILogger<LibraryController> logger, IMapper mapper, ITaskScheduler taskScheduler,
IUnitOfWork unitOfWork, IEventHub eventHub, ILibraryWatcher libraryWatcher, IMemoryCache memoryCache) IUnitOfWork unitOfWork, IEventHub eventHub, ILibraryWatcher libraryWatcher,
IEasyCachingProviderFactory cachingProviderFactory)
{ {
_directoryService = directoryService; _directoryService = directoryService;
_logger = logger; _logger = logger;
@ -51,7 +51,8 @@ public class LibraryController : BaseApiController
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_eventHub = eventHub; _eventHub = eventHub;
_libraryWatcher = libraryWatcher; _libraryWatcher = libraryWatcher;
_memoryCache = memoryCache;
_libraryCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.Library);
} }
/// <summary> /// <summary>
@ -102,7 +103,7 @@ public class LibraryController : BaseApiController
_taskScheduler.ScanLibrary(library.Id); _taskScheduler.ScanLibrary(library.Id);
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
_memoryCache.RemoveByPrefix(CacheKey); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
return Ok(); return Ok();
} }
@ -134,23 +135,19 @@ public class LibraryController : BaseApiController
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[HttpGet] [HttpGet]
public ActionResult<IEnumerable<LibraryDto>> GetLibraries() public async Task<ActionResult<IEnumerable<LibraryDto>>> GetLibraries()
{ {
var username = User.GetUsername(); var username = User.GetUsername();
if (string.IsNullOrEmpty(username)) return Unauthorized(); if (string.IsNullOrEmpty(username)) return Unauthorized();
var cacheKey = CacheKey + username; var cacheKey = CacheKey + username;
if (_memoryCache.TryGetValue(cacheKey, out string cachedValue)) var result = await _libraryCacheProvider.GetAsync<IEnumerable<LibraryDto>>(cacheKey);
{ if (result.HasValue) return Ok(result.Value);
return Ok(JsonConvert.DeserializeObject<IEnumerable<LibraryDto>>(cachedValue));
}
var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username); var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username);
var cacheEntryOptions = new MemoryCacheEntryOptions() await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
.SetSize(1)
.SetAbsoluteExpiration(TimeSpan.FromHours(24));
_memoryCache.Set(cacheKey, JsonConvert.SerializeObject(ret), cacheEntryOptions);
_logger.LogDebug("Caching libraries for {Key}", cacheKey); _logger.LogDebug("Caching libraries for {Key}", cacheKey);
return Ok(ret); return Ok(ret);
} }
@ -211,7 +208,7 @@ public class LibraryController : BaseApiController
{ {
_logger.LogInformation("Added: {SelectedLibraries} to {Username}",libraryString, updateLibraryForUserDto.Username); _logger.LogInformation("Added: {SelectedLibraries} to {Username}",libraryString, updateLibraryForUserDto.Username);
// Bust cache // Bust cache
_memoryCache.RemoveByPrefix(CacheKey); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
return Ok(_mapper.Map<MemberDto>(user)); return Ok(_mapper.Map<MemberDto>(user));
} }
@ -334,7 +331,7 @@ public class LibraryController : BaseApiController
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
_memoryCache.RemoveByPrefix(CacheKey); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
if (chapterIds.Any()) if (chapterIds.Any())
{ {
@ -433,7 +430,7 @@ public class LibraryController : BaseApiController
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "update"), false); MessageFactory.LibraryModifiedEvent(library.Id, "update"), false);
_memoryCache.RemoveByPrefix(CacheKey); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
return Ok(); return Ok();

View File

@ -1,15 +1,12 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.DTOs; using API.DTOs;
using API.DTOs.SeriesDetail;
using API.Services.Plus; using API.Services.Plus;
using EasyCaching.Core;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace API.Controllers; namespace API.Controllers;
@ -20,16 +17,18 @@ public class RatingController : BaseApiController
{ {
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly IRatingService _ratingService; private readonly IRatingService _ratingService;
private readonly IMemoryCache _cache;
private readonly ILogger<RatingController> _logger; private readonly ILogger<RatingController> _logger;
public const string CacheKey = "rating-"; private readonly IEasyCachingProvider _cacheProvider;
public const string CacheKey = "rating_";
public RatingController(ILicenseService licenseService, IRatingService ratingService, IMemoryCache memoryCache, ILogger<RatingController> logger) public RatingController(ILicenseService licenseService, IRatingService ratingService,
ILogger<RatingController> logger, IEasyCachingProviderFactory cachingProviderFactory)
{ {
_licenseService = licenseService; _licenseService = licenseService;
_ratingService = ratingService; _ratingService = ratingService;
_cache = memoryCache;
_logger = logger; _logger = logger;
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
} }
/// <summary> /// <summary>
@ -47,27 +46,16 @@ public class RatingController : BaseApiController
} }
var cacheKey = CacheKey + seriesId; var cacheKey = CacheKey + seriesId;
var setCache = false; var results = await _cacheProvider.GetAsync<IEnumerable<RatingDto>>(cacheKey);
IEnumerable<RatingDto> ratings; if (results.HasValue)
if (_cache.TryGetValue(cacheKey, out string cachedData))
{ {
ratings = JsonConvert.DeserializeObject<IEnumerable<RatingDto>>(cachedData); return Ok(results.Value);
}
else
{
ratings = await _ratingService.GetRatings(seriesId);
setCache = true;
} }
if (setCache) var ratings = await _ratingService.GetRatings(seriesId);
{ await _cacheProvider.SetAsync(cacheKey, ratings, TimeSpan.FromHours(24));
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetSize(1)
.SetAbsoluteExpiration(TimeSpan.FromHours(24));
_cache.Set(cacheKey, JsonConvert.SerializeObject(ratings), cacheEntryOptions);
_logger.LogDebug("Caching external rating for {Key}", cacheKey); _logger.LogDebug("Caching external rating for {Key}", cacheKey);
}
return Ok(ratings); return Ok(ratings);
} }
} }

View File

@ -9,6 +9,7 @@ using API.DTOs.Recommendation;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
using API.Services.Plus; using API.Services.Plus;
using EasyCaching.Core;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -20,16 +21,16 @@ public class RecommendedController : BaseApiController
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IRecommendationService _recommendationService; private readonly IRecommendationService _recommendationService;
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly IMemoryCache _cache; private readonly IEasyCachingProvider _cacheProvider;
public const string CacheKey = "recommendation-"; public const string CacheKey = "recommendation_";
public RecommendedController(IUnitOfWork unitOfWork, IRecommendationService recommendationService, public RecommendedController(IUnitOfWork unitOfWork, IRecommendationService recommendationService,
ILicenseService licenseService, IMemoryCache cache) ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_recommendationService = recommendationService; _recommendationService = recommendationService;
_licenseService = licenseService; _licenseService = licenseService;
_cache = cache; _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
} }
/// <summary> /// <summary>
@ -53,16 +54,14 @@ public class RecommendedController : BaseApiController
} }
var cacheKey = $"{CacheKey}-{seriesId}-{userId}"; var cacheKey = $"{CacheKey}-{seriesId}-{userId}";
if (_cache.TryGetValue(cacheKey, out string cachedData)) var results = await _cacheProvider.GetAsync<RecommendationDto>(cacheKey);
if (results.HasValue)
{ {
return Ok(JsonConvert.DeserializeObject<RecommendationDto>(cachedData)); return Ok(results.Value);
} }
var ret = await _recommendationService.GetRecommendationsForSeries(userId, seriesId); var ret = await _recommendationService.GetRecommendationsForSeries(userId, seriesId);
var cacheEntryOptions = new MemoryCacheEntryOptions() await _cacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromHours(10));
.SetSize(ret.OwnedSeries.Count() + ret.ExternalSeries.Count())
.SetAbsoluteExpiration(TimeSpan.FromHours(10));
_cache.Set(cacheKey, JsonConvert.SerializeObject(ret), cacheEntryOptions);
return Ok(ret); return Ok(ret);
} }

View File

@ -11,6 +11,7 @@ using API.Helpers.Builders;
using API.Services; using API.Services;
using API.Services.Plus; using API.Services.Plus;
using AutoMapper; using AutoMapper;
using EasyCaching.Core;
using Hangfire; using Hangfire;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
@ -26,20 +27,22 @@ public class ReviewController : BaseApiController
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly IReviewService _reviewService; private readonly IReviewService _reviewService;
private readonly IMemoryCache _cache;
private readonly IScrobblingService _scrobblingService; private readonly IScrobblingService _scrobblingService;
public const string CacheKey = "review-"; private readonly IEasyCachingProvider _cacheProvider;
public const string CacheKey = "review_";
public ReviewController(ILogger<ReviewController> logger, IUnitOfWork unitOfWork, ILicenseService licenseService, public ReviewController(ILogger<ReviewController> logger, IUnitOfWork unitOfWork, ILicenseService licenseService,
IMapper mapper, IReviewService reviewService, IMemoryCache cache, IScrobblingService scrobblingService) IMapper mapper, IReviewService reviewService, IScrobblingService scrobblingService,
IEasyCachingProviderFactory cachingProviderFactory)
{ {
_logger = logger; _logger = logger;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_licenseService = licenseService; _licenseService = licenseService;
_mapper = mapper; _mapper = mapper;
_reviewService = reviewService; _reviewService = reviewService;
_cache = cache;
_scrobblingService = scrobblingService; _scrobblingService = scrobblingService;
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
} }
@ -63,15 +66,26 @@ public class ReviewController : BaseApiController
var cacheKey = CacheKey + seriesId; var cacheKey = CacheKey + seriesId;
IEnumerable<UserReviewDto> externalReviews; IEnumerable<UserReviewDto> externalReviews;
var setCache = false; var setCache = false;
if (_cache.TryGetValue(cacheKey, out string cachedData))
var result = await _cacheProvider.GetAsync<IEnumerable<UserReviewDto>>(cacheKey);
if (result.HasValue)
{ {
externalReviews = JsonConvert.DeserializeObject<IEnumerable<UserReviewDto>>(cachedData); externalReviews = result.Value;
} }
else else
{ {
externalReviews = await _reviewService.GetReviewsForSeries(userId, seriesId); externalReviews = await _reviewService.GetReviewsForSeries(userId, seriesId);
setCache = true; setCache = true;
} }
// if (_cache.TryGetValue(cacheKey, out string cachedData))
// {
// externalReviews = JsonConvert.DeserializeObject<IEnumerable<UserReviewDto>>(cachedData);
// }
// else
// {
// externalReviews = await _reviewService.GetReviewsForSeries(userId, seriesId);
// setCache = true;
// }
// Fetch external reviews and splice them in // Fetch external reviews and splice them in
foreach (var r in externalReviews) foreach (var r in externalReviews)
@ -81,10 +95,11 @@ public class ReviewController : BaseApiController
if (setCache) if (setCache)
{ {
var cacheEntryOptions = new MemoryCacheEntryOptions() // var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetSize(userRatings.Count) // .SetSize(userRatings.Count)
.SetAbsoluteExpiration(TimeSpan.FromHours(10)); // .SetAbsoluteExpiration(TimeSpan.FromHours(10));
_cache.Set(cacheKey, JsonConvert.SerializeObject(externalReviews), cacheEntryOptions); //_cache.Set(cacheKey, JsonConvert.SerializeObject(externalReviews), cacheEntryOptions);
await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10));
_logger.LogDebug("Caching external reviews for {Key}", cacheKey); _logger.LogDebug("Caching external reviews for {Key}", cacheKey);
} }

View File

@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
@ -15,12 +14,11 @@ using API.Extensions;
using API.Helpers; using API.Helpers;
using API.Services; using API.Services;
using API.Services.Plus; using API.Services.Plus;
using Kavita.Common; using EasyCaching.Core;
using Kavita.Common.Extensions; using Kavita.Common.Extensions;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Controllers; namespace API.Controllers;
@ -31,19 +29,25 @@ public class SeriesController : BaseApiController
private readonly ITaskScheduler _taskScheduler; private readonly ITaskScheduler _taskScheduler;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ISeriesService _seriesService; private readonly ISeriesService _seriesService;
private readonly IMemoryCache _cache;
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly IEasyCachingProvider _ratingCacheProvider;
private readonly IEasyCachingProvider _reviewCacheProvider;
private readonly IEasyCachingProvider _recommendationCacheProvider;
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork,
ISeriesService seriesService, IMemoryCache cache, ILicenseService licenseService) ISeriesService seriesService, ILicenseService licenseService,
IEasyCachingProviderFactory cachingProviderFactory)
{ {
_logger = logger; _logger = logger;
_taskScheduler = taskScheduler; _taskScheduler = taskScheduler;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_seriesService = seriesService; _seriesService = seriesService;
_cache = cache;
_licenseService = licenseService; _licenseService = licenseService;
_ratingCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
_reviewCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
_recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
} }
[HttpPost] [HttpPost]
@ -344,12 +348,13 @@ public class SeriesController : BaseApiController
if (await _licenseService.HasActiveLicense()) if (await _licenseService.HasActiveLicense())
{ {
_logger.LogDebug("Clearing cache as series weblinks may have changed"); _logger.LogDebug("Clearing cache as series weblinks may have changed");
_cache.Remove(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId); await _reviewCacheProvider.RemoveAsync(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
_cache.Remove(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId); await _ratingCacheProvider.RemoveAsync(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
var allUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(s => s.Id); var allUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(s => s.Id);
foreach (var userId in allUsers) foreach (var userId in allUsers)
{ {
_cache.Remove(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}"); await _recommendationCacheProvider.RemoveAsync(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}");
} }
} }

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants;
using API.Data; using API.Data;
using API.DTOs.Jobs; using API.DTOs.Jobs;
using API.DTOs.MediaErrors; using API.DTOs.MediaErrors;
@ -13,6 +14,7 @@ using API.Extensions;
using API.Helpers; using API.Helpers;
using API.Services; using API.Services;
using API.Services.Tasks; using API.Services.Tasks;
using EasyCaching.Core;
using Hangfire; using Hangfire;
using Hangfire.Storage; using Hangfire.Storage;
using Kavita.Common; using Kavita.Common;
@ -38,12 +40,12 @@ public class ServerController : BaseApiController
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly ITaskScheduler _taskScheduler; private readonly ITaskScheduler _taskScheduler;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IMemoryCache _memoryCache; private readonly IEasyCachingProviderFactory _cachingProviderFactory;
public ServerController(ILogger<ServerController> logger, public ServerController(ILogger<ServerController> logger,
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
ICleanupService cleanupService, IScannerService scannerService, IAccountService accountService, ICleanupService cleanupService, IScannerService scannerService, IAccountService accountService,
ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IMemoryCache memoryCache) ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEasyCachingProviderFactory cachingProviderFactory)
{ {
_logger = logger; _logger = logger;
_backupService = backupService; _backupService = backupService;
@ -55,7 +57,7 @@ public class ServerController : BaseApiController
_accountService = accountService; _accountService = accountService;
_taskScheduler = taskScheduler; _taskScheduler = taskScheduler;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_memoryCache = memoryCache; _cachingProviderFactory = cachingProviderFactory;
} }
/// <summary> /// <summary>
@ -244,9 +246,15 @@ public class ServerController : BaseApiController
/// <returns></returns> /// <returns></returns>
[Authorize("RequireAdminRole")] [Authorize("RequireAdminRole")]
[HttpPost("bust-review-and-rec-cache")] [HttpPost("bust-review-and-rec-cache")]
public ActionResult BustReviewAndRecCache() public async Task<ActionResult> BustReviewAndRecCache()
{ {
_memoryCache.Clear(); _logger.LogInformation("Busting Kavita+ Cache");
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
await provider.FlushAsync();
provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
await provider.FlushAsync();
provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
await provider.FlushAsync();
return Ok(); return Ok();
} }

View File

@ -191,6 +191,14 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting); _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);
}
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
{ {
if (OsInfo.IsDocker) continue; if (OsInfo.IsDocker) continue;

View File

@ -0,0 +1,7 @@
namespace API.DTOs.Device;
public class SendSeriesToDeviceDto
{
public int DeviceId { get; set; }
public int SeriesId { get; set; }
}

View File

@ -72,4 +72,8 @@ public class ServerSettingDto
/// The Host name (ie Reverse proxy domain name) for the server /// The Host name (ie Reverse proxy domain name) for the server
/// </summary> /// </summary>
public string HostName { get; set; } public string HostName { get; set; }
/// <summary>
/// The size in MB for Caching API data
/// </summary>
public long CacheSize { get; set; }
} }

View File

@ -136,7 +136,8 @@ public class AppUserProgressRepository : IAppUserProgressRepository
{ {
return await _context.AppUserProgresses return await _context.AppUserProgresses
.Select(d => d.LastModifiedUtc) .Select(d => d.LastModifiedUtc)
.MaxAsync(); .OrderByDescending(d => d)
.FirstOrDefaultAsync();
} }
public async Task<ProgressDto> GetUserProgressDtoAsync(int chapterId, int userId) public async Task<ProgressDto> GetUserProgressDtoAsync(int chapterId, int userId)

View File

@ -89,10 +89,10 @@ public static class Seed
}, },
new() new()
{ {
Key = ServerSettingKey.Port, Value = "5000" Key = ServerSettingKey.Port, Value = Configuration.DefaultHttpPort + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json }, // Not used from DB, but DB is sync with appSettings.json
new() { new() {
Key = ServerSettingKey.IpAddresses, Value = "0.0.0.0,::" Key = ServerSettingKey.IpAddresses, Value = Configuration.DefaultIpAddresses
}, // Not used from DB, but DB is sync with appSettings.json }, // Not used from DB, but DB is sync with appSettings.json
new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
new() {Key = ServerSettingKey.EnableOpds, Value = "true"}, new() {Key = ServerSettingKey.EnableOpds, Value = "true"},
@ -108,6 +108,9 @@ public static class Seed
new() {Key = ServerSettingKey.HostName, Value = string.Empty}, new() {Key = ServerSettingKey.HostName, Value = string.Empty},
new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()}, new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()},
new() {Key = ServerSettingKey.LicenseKey, Value = string.Empty}, new() {Key = ServerSettingKey.LicenseKey, Value = string.Empty},
new() {
Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json
}.ToArray()); }.ToArray());
foreach (var defaultSetting in DefaultSettings) foreach (var defaultSetting in DefaultSettings)
@ -130,7 +133,6 @@ public static class Seed
directoryService.CacheDirectory + string.Empty; directoryService.CacheDirectory + string.Empty;
context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value =
DirectoryService.BackupDirectory + string.Empty; DirectoryService.BackupDirectory + string.Empty;
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }

View File

@ -128,5 +128,10 @@ public enum ServerSettingKey
/// </summary> /// </summary>
[Description("LicenseKey")] [Description("LicenseKey")]
LicenseKey = 23, LicenseKey = 23,
/// <summary>
/// The size in MB for Caching API data
/// </summary>
[Description("Cache")]
CacheSize = 24,
} }

View File

@ -79,6 +79,13 @@ public static class ApplicationServiceExtensions
{ {
options.UseInMemory(EasyCacheProfiles.Favicon); options.UseInMemory(EasyCacheProfiles.Favicon);
options.UseInMemory(EasyCacheProfiles.License); options.UseInMemory(EasyCacheProfiles.License);
options.UseInMemory(EasyCacheProfiles.Library);
options.UseInMemory(EasyCacheProfiles.RevokedJwt);
// KavitaPlus stuff
options.UseInMemory(EasyCacheProfiles.KavitaPlusReviews);
options.UseInMemory(EasyCacheProfiles.KavitaPlusRecommendations);
options.UseInMemory(EasyCacheProfiles.KavitaPlusRatings);
}); });
services.AddMemoryCache(options => services.AddMemoryCache(options =>

View File

@ -1,35 +0,0 @@
using System.Reflection;
using Microsoft.Extensions.Caching.Memory;
namespace API.Extensions;
public static class MemoryCacheExtensions
{
public static void RemoveByPrefix(this IMemoryCache memoryCache, string prefix)
{
if (memoryCache is not MemoryCache concreteMemoryCache) return;
var cacheEntriesCollectionInfo = typeof(MemoryCache)
.GetProperty("EntriesCollection", BindingFlags.NonPublic | BindingFlags.Instance);
var cacheEntriesCollection = cacheEntriesCollectionInfo?.GetValue(concreteMemoryCache) as dynamic;
if (cacheEntriesCollection == null) return;
foreach (var cacheItem in cacheEntriesCollection)
{
// Check if the cache key starts with the given prefix
if (cacheItem.GetType().GetProperty("Key").GetValue(cacheItem) is string cacheItemKey && cacheItemKey.StartsWith(prefix))
{
concreteMemoryCache.Remove(cacheItemKey);
}
}
}
public static void Clear(this IMemoryCache memoryCache)
{
if (memoryCache is MemoryCache concreteMemoryCache)
{
concreteMemoryCache.Clear();
}
}
}

View File

@ -70,6 +70,9 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
case ServerSettingKey.HostName: case ServerSettingKey.HostName:
destination.HostName = row.Value; destination.HostName = row.Value;
break; break;
case ServerSettingKey.CacheSize:
destination.CacheSize = long.Parse(row.Value);
break;
} }
} }

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
@ -40,6 +41,9 @@ public class RatingService : IRatingService
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Chapters | SeriesIncludes.Volumes); SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Chapters | SeriesIncludes.Volumes);
// Don't send any ratings back for Comic libraries as Kavita+ doesn't have any providers for that
if (series == null || series.Library.Type == LibraryType.Comic) return ImmutableList<RatingDto>.Empty;
return await GetRatings(license.Value, series); return await GetRatings(license.Value, series);
} }

View File

@ -74,7 +74,7 @@ public class RecommendationService : IRecommendationService
var series = var series =
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters); SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters);
if (series == null) return new RecommendationDto(); if (series == null || series.Library.Type == LibraryType.Comic) return new RecommendationDto();
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);

View File

@ -466,10 +466,11 @@ public class ScrobblingService : IScrobblingService
var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()) var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync())
.Where(l => userId == 0 || userId == l.Id) .Where(l => userId == 0 || userId == l.Id)
.Select(u => u.Id); .Select(u => u.Id);
if (!await _licenseService.HasActiveLicense()) return;
foreach (var uId in userIds) foreach (var uId in userIds)
{ {
if (!await _licenseService.HasActiveLicense()) continue;
var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId); var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId);
foreach (var wtr in wantToRead) foreach (var wtr in wantToRead)
{ {
@ -505,6 +506,7 @@ public class ScrobblingService : IScrobblingService
foreach (var series in seriesWithProgress) foreach (var series in seriesWithProgress)
{ {
if (!libAllowsScrobbling[series.LibraryId]) continue;
await ScrobbleReadingUpdate(uId, series.Id); await ScrobbleReadingUpdate(uId, series.Id);
} }
@ -687,7 +689,10 @@ public class ScrobblingService : IScrobblingService
_logger.LogDebug("Processing Reading Events: {Count} / {Total}", progressCounter, totalProgress); _logger.LogDebug("Processing Reading Events: {Count} / {Total}", progressCounter, totalProgress);
progressCounter++; progressCounter++;
// Check if this media item can even be processed for this user // Check if this media item can even be processed for this user
if (!DoesUserHaveProviderAndValid(evt)) continue; if (!DoesUserHaveProviderAndValid(evt))
{
continue;
}
var count = await SetAndCheckRateLimit(userRateLimits, evt.AppUser, license.Value); var count = await SetAndCheckRateLimit(userRateLimits, evt.AppUser, license.Value);
if (count == 0) if (count == 0)
{ {

View File

@ -3,5 +3,5 @@
"Port": 5000, "Port": 5000,
"IpAddresses": "", "IpAddresses": "",
"BaseUrl": "/", "BaseUrl": "/",
"Cache": 50 "Cache": 90
} }

View File

@ -13,7 +13,7 @@ public static class Configuration
public const int DefaultHttpPort = 5000; public const int DefaultHttpPort = 5000;
public const int DefaultTimeOutSecs = 90; public const int DefaultTimeOutSecs = 90;
public const string DefaultXFrameOptions = "SAMEORIGIN"; public const string DefaultXFrameOptions = "SAMEORIGIN";
public const int DefaultCacheMemory = 50; public const long DefaultCacheMemory = 75;
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static string KavitaPlusApiUrl = "https://plus.kavitareader.com"; public static string KavitaPlusApiUrl = "https://plus.kavitareader.com";
@ -42,7 +42,7 @@ public static class Configuration
set => SetBaseUrl(GetAppSettingFilename(), value); set => SetBaseUrl(GetAppSettingFilename(), value);
} }
public static int CacheSize public static long CacheSize
{ {
get => GetCacheSize(GetAppSettingFilename()); get => GetCacheSize(GetAppSettingFilename());
set => SetCacheSize(GetAppSettingFilename(), value); set => SetCacheSize(GetAppSettingFilename(), value);
@ -69,15 +69,8 @@ public static class Configuration
try try
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<dynamic>(json); var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
const string key = "TokenKey"; return jsonObj.TokenKey;
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
{
return tokenElement.GetString();
}
return string.Empty;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -144,29 +137,23 @@ public static class Configuration
private static int GetPort(string filePath) private static int GetPort(string filePath)
{ {
const int defaultPort = 5000;
if (OsInfo.IsDocker) if (OsInfo.IsDocker)
{ {
return defaultPort; return DefaultHttpPort;
} }
try try
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<dynamic>(json); var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
const string key = "Port"; return jsonObj.Port;
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
{
return tokenElement.GetInt32();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine("Error writing app settings: " + ex.Message); Console.WriteLine("Error writing app settings: " + ex.Message);
} }
return defaultPort; return DefaultHttpPort;
} }
#endregion #endregion
@ -204,13 +191,8 @@ public static class Configuration
try try
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<dynamic>(json); var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
const string key = "IpAddresses"; return jsonObj.IpAddresses;
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
{
return tokenElement.GetString();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -224,16 +206,12 @@ public static class Configuration
#region BaseUrl #region BaseUrl
private static string GetBaseUrl(string filePath) private static string GetBaseUrl(string filePath)
{ {
try try
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<dynamic>(json); var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
const string key = "BaseUrl";
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) var baseUrl = jsonObj.BaseUrl;
{
var baseUrl = tokenElement.GetString();
if (!string.IsNullOrEmpty(baseUrl)) if (!string.IsNullOrEmpty(baseUrl))
{ {
baseUrl = !baseUrl.StartsWith('/') baseUrl = !baseUrl.StartsWith('/')
@ -246,8 +224,6 @@ public static class Configuration
return baseUrl; return baseUrl;
} }
return DefaultBaseUrl;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -284,7 +260,7 @@ public static class Configuration
#endregion #endregion
#region CacheSize #region CacheSize
private static void SetCacheSize(string filePath, int cache) private static void SetCacheSize(string filePath, long cache)
{ {
if (cache <= 0) return; if (cache <= 0) return;
try try
@ -301,18 +277,14 @@ public static class Configuration
} }
} }
private static int GetCacheSize(string filePath) private static long GetCacheSize(string filePath)
{ {
try try
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<dynamic>(json); var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
const string key = "Port";
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) return jsonObj.Cache;
{
return tokenElement.GetInt32();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -336,14 +308,8 @@ public static class Configuration
try try
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<dynamic>(json); var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
const string key = "XFrameOrigins"; return !string.IsNullOrEmpty(jsonObj.XFrameOrigins) ? jsonObj.XFrameOrigins : DefaultXFrameOptions;
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
{
var origins = tokenElement.GetString();
return !string.IsNullOrEmpty(origins) ? origins : DefaultBaseUrl;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -364,6 +330,8 @@ public static class Configuration
// ReSharper disable once MemberHidesStaticFromOuterClass // ReSharper disable once MemberHidesStaticFromOuterClass
public string BaseUrl { get; set; } public string BaseUrl { get; set; }
// ReSharper disable once MemberHidesStaticFromOuterClass // ReSharper disable once MemberHidesStaticFromOuterClass
public int Cache { get; set; } public long Cache { get; set; }
// ReSharper disable once MemberHidesStaticFromOuterClass
public string XFrameOrigins { get; set; } = DefaultXFrameOptions;
} }
} }

View File

@ -578,15 +578,13 @@ export class ActionService implements OnDestroy {
}); });
} }
private async promptIfForce(extraContent: string = '') { sendSeriesToDevice(seriesId: number, device: Device, callback?: VoidActionCallback) {
// Prompt user if we should do a force or not this.deviceSerivce.sendSeriesTo(seriesId, device.id).subscribe(() => {
const config = this.confirmService.defaultConfirm; this.toastr.success('File(s) emailed to ' + device.name);
config.header = 'Force Scan'; if (callback) {
config.buttons = [ callback();
{text: 'Yes', type: 'secondary'},
{text: 'No', type: 'primary'},
];
const msg = 'Do you want to force this scan? This is will ignore optimizations that reduce processing and I/O. ' + extraContent;
return !await this.confirmService.confirm(msg, config); // Not because primary is the false state
} }
});
}
} }

View File

@ -54,5 +54,9 @@ export class DeviceService {
return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, TextResonse); return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, TextResonse);
} }
sendSeriesTo(seriesId: number, deviceId: number) {
return this.httpClient.post(this.baseUrl + 'device/send-series-to', {deviceId, seriesId}, TextResonse);
}
} }

View File

@ -17,4 +17,5 @@ export interface ServerSettings {
totalLogs: number; totalLogs: number;
enableFolderWatching: boolean; enableFolderWatching: boolean;
hostName: string; hostName: string;
cacheSize: number;
} }

View File

@ -36,7 +36,7 @@
<app-manage-tasks-settings></app-manage-tasks-settings> <app-manage-tasks-settings></app-manage-tasks-settings>
</ng-container> </ng-container>
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus"> <ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
<p>Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">premium benefits</a> today!</p> <p>Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">premium benefits</a> today! <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
<app-license></app-license> <app-license></app-license>
</ng-container> </ng-container>
<ng-container *ngIf="tab.fragment === TabID.Plugins"> <ng-container *ngIf="tab.fragment === TabID.Plugins">

View File

@ -1,7 +1,7 @@
<div class="container-fluid"> <div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined"> <form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<strong>Notice:</strong> Changing Port, Base Url or IPs requires a manual restart of Kavita to take effect. <strong>Notice:</strong> Changing Port, Base Url, Cache Size or IPs requires a manual restart of Kavita to take effect.
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -97,7 +97,26 @@
</div> </div>
</div> </div>
<div class="mb-3"> <div class="row g-0 mb-2 mt-3">
<div class="col-md-4 col-sm-12 pe-2">
<label for="cache-size" class="form-label">Cache Size</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheSizeTooltip" role="button" tabindex="0"></i>
<ng-template #cacheSizeTooltip>The amount of memory allowed for caching heavy APIs. Default is 50MB.</ng-template>
<span class="visually-hidden" id="cache-size-help">The amount of memory allowed for caching heavy APIs. Default is 50MB.</span>
<input id="cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize"
type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="settingsForm.get('cacheSize')?.invalid && settingsForm.get('cacheSize')?.touched">
<ng-container *ngIf="settingsForm.get('cacheSize')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.min">
You must have at least 50 MB.
</p>
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
</div>
<div class="mb-3 mt-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label> <label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<p class="accent" id="collection-info">Send anonymous usage data to Kavita's servers. This includes information on certain features used, number of files, OS version, Kavita install version, CPU, and memory. We will use this information to prioritize features, bug fixes, and performance tuning. Requires restart to take effect. See the <a href="https://wiki.kavitareader.com/en/faq" rel="noopener noreferrer" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p> <p class="accent" id="collection-info">Send anonymous usage data to Kavita's servers. This includes information on certain features used, number of files, OS version, Kavita install version, CPU, and memory. We will use this information to prioritize features, bug fixes, and performance tuning. Requires restart to take effect. See the <a href="https://wiki.kavitareader.com/en/faq" rel="noopener noreferrer" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p>
<div class="form-check form-switch"> <div class="form-check form-switch">

View File

@ -52,6 +52,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.pattern(/^(\/[\w-]+)*\/$/)])); this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.pattern(/^(\/[\w-]+)*\/$/)]));
this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required])); this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required]));
this.settingsForm.addControl('totalBackups', new FormControl(this.serverSettings.totalBackups, [Validators.required, Validators.min(1), Validators.max(30)])); this.settingsForm.addControl('totalBackups', new FormControl(this.serverSettings.totalBackups, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('cacheSize', new FormControl(this.serverSettings.cacheSize, [Validators.required, Validators.min(50)]));
this.settingsForm.addControl('totalLogs', new FormControl(this.serverSettings.totalLogs, [Validators.required, Validators.min(1), Validators.max(30)])); this.settingsForm.addControl('totalLogs', new FormControl(this.serverSettings.totalLogs, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('enableFolderWatching', new FormControl(this.serverSettings.enableFolderWatching, [Validators.required])); this.settingsForm.addControl('enableFolderWatching', new FormControl(this.serverSettings.enableFolderWatching, [Validators.required]));
this.settingsForm.addControl('encodeMediaAs', new FormControl(this.serverSettings.encodeMediaAs, [])); this.settingsForm.addControl('encodeMediaAs', new FormControl(this.serverSettings.encodeMediaAs, []));
@ -82,6 +83,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('enableFolderWatching')?.setValue(this.serverSettings.enableFolderWatching); this.settingsForm.get('enableFolderWatching')?.setValue(this.serverSettings.enableFolderWatching);
this.settingsForm.get('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs); this.settingsForm.get('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs);
this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName); this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName);
this.settingsForm.get('cacheSize')?.setValue(this.serverSettings.cacheSize);
this.settingsForm.markAsPristine(); this.settingsForm.markAsPristine();
} }

View File

@ -60,6 +60,7 @@
color: hsla(0,0%,100%,.7); color: hsla(0,0%,100%,.7);
height: 25px; height: 25px;
text-align: center; text-align: center;
padding: 0px 5px;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
background: none; background: none;
border: 0; border: 0;
@ -68,7 +69,6 @@
line-height: inherit; line-height: inherit;
margin: 0; margin: 0;
outline: none; outline: none;
padding: 0;
text-align: inherit; text-align: inherit;
text-decoration: none; text-decoration: none;
touch-action: manipulation; touch-action: manipulation;

View File

@ -21,6 +21,7 @@ import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
import {CommonModule} from "@angular/common"; import {CommonModule} from "@angular/common";
import {CardItemComponent} from "../card-item/card-item.component"; import {CardItemComponent} from "../card-item/card-item.component";
import {RelationshipPipe} from "../../pipe/relationship.pipe"; import {RelationshipPipe} from "../../pipe/relationship.pipe";
import {Device} from "../../_models/device/device";
@Component({ @Component({
selector: 'app-series-card', selector: 'app-series-card',
@ -120,6 +121,10 @@ export class SeriesCardComponent implements OnInit, OnChanges {
case (Action.AnalyzeFiles): case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series); this.actionService.analyzeFilesForSeries(series);
break; break;
case Action.SendTo:
const device = (action._extra!.data as Device);
this.actionService.sendSeriesToDevice(series.id, device);
break;
default: default:
break; break;
} }

View File

@ -5,13 +5,11 @@ import {
EventEmitter, EventEmitter,
HostListener, HostListener,
inject, inject,
OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Subject } from 'rxjs'; import { take } from 'rxjs/operators';
import { debounceTime, take, takeUntil } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service'; import { BulkSelectionService } from '../cards/bulk-selection.service';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service'; import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { SeriesAddedEvent } from '../_models/events/series-added-event';
@ -39,6 +37,7 @@ import { NgFor, NgIf, DecimalPipe } from '@angular/common';
import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'; import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap';
import { CardActionablesComponent } from '../cards/card-item/card-actionables/card-actionables.component'; import { CardActionablesComponent } from '../cards/card-item/card-actionables/card-actionables.component';
import { SideNavCompanionBarComponent } from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import { SideNavCompanionBarComponent } from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {Device} from "../_models/device/device";
@Component({ @Component({
selector: 'app-library-detail', selector: 'app-library-detail',
@ -234,6 +233,8 @@ export class LibraryDetailComponent implements OnInit {
} }
} }
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {
action.callback(action, undefined); action.callback(action, undefined);

View File

@ -329,14 +329,14 @@
<form [formGroup]="releaseYearRange" class="d-flex justify-content-between"> <form [formGroup]="releaseYearRange" class="d-flex justify-content-between">
<div class="mb-3"> <div class="mb-3">
<label for="release-year-min" class="form-label">Release</label> <label for="release-year-min" class="form-label">Release</label>
<input type="text" id="release-year-min" formControlName="min" class="form-control" style="width: 62px" placeholder="Min" (keyup.enter)="apply()"> <input type="number" id="release-year-min" formControlName="min" class="form-control custom-number" style="width: 62px" placeholder="Min" (keyup.enter)="apply()">
</div> </div>
<div style="margin-top: 37px !important;"> <div style="margin-top: 37px !important;">
<i class="fa-solid fa-minus" aria-hidden="true"></i> <i class="fa-solid fa-minus" aria-hidden="true"></i>
</div> </div>
<div class="mb-3" style="margin-top: 0.5rem"> <div class="mb-3" style="margin-top: 0.5rem">
<label for="release-year-max" class="form-label"><span class="visually-hidden">Max</span></label> <label for="release-year-max" class="form-label"><span class="visually-hidden">Max</span></label>
<input type="text" id="release-year-max" formControlName="max" class="form-control" style="width: 62px" placeholder="Max" (keyup.enter)="apply()"> <input type="number" id="release-year-max" formControlName="max" class="form-control custom-number" style="width: 62px" placeholder="Max" (keyup.enter)="apply()">
</div> </div>
</form> </form>
</div> </div>

View File

@ -0,0 +1,12 @@
/* Works for Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Works for Firefox */
input[type="number"] {
-moz-appearance: textfield;
}

View File

@ -137,8 +137,8 @@ export class MetadataFilterComponent implements OnInit {
}); });
this.releaseYearRange = new FormGroup({ this.releaseYearRange = new FormGroup({
min: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999)]), min: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)]),
max: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999)]) max: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)])
}); });
this.readProgressGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => { this.readProgressGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => {

View File

@ -22,8 +22,6 @@ export class ChangeEmailComponent implements OnInit {
form: FormGroup = new FormGroup({}); form: FormGroup = new FormGroup({});
user: User | undefined = undefined; user: User | undefined = undefined;
hasChangePasswordAbility: Observable<boolean> = of(false);
passwordsMatch = false;
errors: string[] = []; errors: string[] = [];
isViewMode: boolean = true; isViewMode: boolean = true;
emailLink: string = ''; emailLink: string = '';

View File

@ -1,3 +1,7 @@
.nav {
--bs-nav-link-disabled-color: rgb(154 187 219 / 75%);
}
.nav-link { .nav-link {
color: var(--nav-link-text-color); color: var(--nav-link-text-color);

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.7.4.0" "version": "0.7.4.1"
}, },
"servers": [ "servers": [
{ {
@ -1643,6 +1643,37 @@
} }
} }
}, },
"/api/Device/send-series-to": {
"post": {
"tags": [
"Device"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SendSeriesToDeviceDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/SendSeriesToDeviceDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/SendSeriesToDeviceDto"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Download/volume-size": { "/api/Download/volume-size": {
"get": { "get": {
"tags": [ "tags": [
@ -14650,6 +14681,20 @@
"additionalProperties": false, "additionalProperties": false,
"description": "Represents all Search results for a query" "description": "Represents all Search results for a query"
}, },
"SendSeriesToDeviceDto": {
"type": "object",
"properties": {
"deviceId": {
"type": "integer",
"format": "int32"
},
"seriesId": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"SendToDeviceDto": { "SendToDeviceDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -15782,6 +15827,11 @@
"type": "string", "type": "string",
"description": "The Host name (ie Reverse proxy domain name) for the server", "description": "The Host name (ie Reverse proxy domain name) for the server",
"nullable": true "nullable": true
},
"cacheSize": {
"type": "integer",
"description": "The size in MB for Caching API data",
"format": "int64"
} }
}, },
"additionalProperties": false "additionalProperties": false