diff --git a/API/Constants/CacheProfiles.cs b/API/Constants/CacheProfiles.cs index 4007eddf4..dd25f27a6 100644 --- a/API/Constants/CacheProfiles.cs +++ b/API/Constants/CacheProfiles.cs @@ -11,4 +11,11 @@ public static class EasyCacheProfiles /// If a user's license is valid /// public const string License = "license"; + /// + /// Cache the libraries on the server + /// + public const string Library = "library"; + public const string KavitaPlusReviews = "kavita+reviews"; + public const string KavitaPlusRecommendations = "kavita+recommendations"; + public const string KavitaPlusRatings = "kavita+ratings"; } diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs index d709020eb..ac209593f 100644 --- a/API/Controllers/DeviceController.cs +++ b/API/Controllers/DeviceController.cs @@ -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."); 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 { var success = await _deviceService.SendTo(dto.ChapterIds, dto.DeviceId); @@ -100,7 +101,8 @@ public class DeviceController : BaseApiController } 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"); @@ -108,6 +110,42 @@ public class DeviceController : BaseApiController + [HttpPost("send-series-to")] + public async Task 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"); + } + + + } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 586b8f216..16b8f948d 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -18,11 +18,10 @@ using API.Services; using API.Services.Tasks.Scanner; using API.SignalR; using AutoMapper; +using EasyCaching.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; using TaskScheduler = API.Services.TaskScheduler; namespace API.Controllers; @@ -37,12 +36,13 @@ public class LibraryController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly ILibraryWatcher _libraryWatcher; - private readonly IMemoryCache _memoryCache; + private readonly IEasyCachingProvider _libraryCacheProvider; private const string CacheKey = "library_"; public LibraryController(IDirectoryService directoryService, ILogger logger, IMapper mapper, ITaskScheduler taskScheduler, - IUnitOfWork unitOfWork, IEventHub eventHub, ILibraryWatcher libraryWatcher, IMemoryCache memoryCache) + IUnitOfWork unitOfWork, IEventHub eventHub, ILibraryWatcher libraryWatcher, + IEasyCachingProviderFactory cachingProviderFactory) { _directoryService = directoryService; _logger = logger; @@ -51,7 +51,8 @@ public class LibraryController : BaseApiController _unitOfWork = unitOfWork; _eventHub = eventHub; _libraryWatcher = libraryWatcher; - _memoryCache = memoryCache; + + _libraryCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.Library); } /// @@ -102,7 +103,7 @@ public class LibraryController : BaseApiController _taskScheduler.ScanLibrary(library.Id); await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); - _memoryCache.RemoveByPrefix(CacheKey); + await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); return Ok(); } @@ -134,23 +135,19 @@ public class LibraryController : BaseApiController /// /// [HttpGet] - public ActionResult> GetLibraries() + public async Task>> GetLibraries() { var username = User.GetUsername(); if (string.IsNullOrEmpty(username)) return Unauthorized(); var cacheKey = CacheKey + username; - if (_memoryCache.TryGetValue(cacheKey, out string cachedValue)) - { - return Ok(JsonConvert.DeserializeObject>(cachedValue)); - } + var result = await _libraryCacheProvider.GetAsync>(cacheKey); + if (result.HasValue) return Ok(result.Value); var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username); - var cacheEntryOptions = new MemoryCacheEntryOptions() - .SetSize(1) - .SetAbsoluteExpiration(TimeSpan.FromHours(24)); - _memoryCache.Set(cacheKey, JsonConvert.SerializeObject(ret), cacheEntryOptions); + await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); _logger.LogDebug("Caching libraries for {Key}", cacheKey); + return Ok(ret); } @@ -211,7 +208,7 @@ public class LibraryController : BaseApiController { _logger.LogInformation("Added: {SelectedLibraries} to {Username}",libraryString, updateLibraryForUserDto.Username); // Bust cache - _memoryCache.RemoveByPrefix(CacheKey); + await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); return Ok(_mapper.Map(user)); } @@ -334,7 +331,7 @@ public class LibraryController : BaseApiController await _unitOfWork.CommitAsync(); - _memoryCache.RemoveByPrefix(CacheKey); + await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); if (chapterIds.Any()) { @@ -433,7 +430,7 @@ public class LibraryController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "update"), false); - _memoryCache.RemoveByPrefix(CacheKey); + await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); return Ok(); diff --git a/API/Controllers/RatingController.cs b/API/Controllers/RatingController.cs index 22bc18c91..accd6ccaa 100644 --- a/API/Controllers/RatingController.cs +++ b/API/Controllers/RatingController.cs @@ -1,15 +1,12 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; using API.Constants; using API.DTOs; -using API.DTOs.SeriesDetail; using API.Services.Plus; +using EasyCaching.Core; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace API.Controllers; @@ -20,16 +17,18 @@ public class RatingController : BaseApiController { private readonly ILicenseService _licenseService; private readonly IRatingService _ratingService; - private readonly IMemoryCache _cache; private readonly ILogger _logger; - public const string CacheKey = "rating-"; + private readonly IEasyCachingProvider _cacheProvider; + public const string CacheKey = "rating_"; - public RatingController(ILicenseService licenseService, IRatingService ratingService, IMemoryCache memoryCache, ILogger logger) + public RatingController(ILicenseService licenseService, IRatingService ratingService, + ILogger logger, IEasyCachingProviderFactory cachingProviderFactory) { _licenseService = licenseService; _ratingService = ratingService; - _cache = memoryCache; _logger = logger; + + _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings); } /// @@ -47,27 +46,16 @@ public class RatingController : BaseApiController } var cacheKey = CacheKey + seriesId; - var setCache = false; - IEnumerable ratings; - if (_cache.TryGetValue(cacheKey, out string cachedData)) + var results = await _cacheProvider.GetAsync>(cacheKey); + if (results.HasValue) { - ratings = JsonConvert.DeserializeObject>(cachedData); - } - else - { - ratings = await _ratingService.GetRatings(seriesId); - setCache = true; - } - - if (setCache) - { - 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); + return Ok(results.Value); } + var ratings = await _ratingService.GetRatings(seriesId); + await _cacheProvider.SetAsync(cacheKey, ratings, TimeSpan.FromHours(24)); + _logger.LogDebug("Caching external rating for {Key}", cacheKey); return Ok(ratings); + } } diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs index 31146990e..14c9b7bfc 100644 --- a/API/Controllers/RecommendedController.cs +++ b/API/Controllers/RecommendedController.cs @@ -9,6 +9,7 @@ using API.DTOs.Recommendation; using API.Extensions; using API.Helpers; using API.Services.Plus; +using EasyCaching.Core; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Newtonsoft.Json; @@ -20,16 +21,16 @@ public class RecommendedController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IRecommendationService _recommendationService; private readonly ILicenseService _licenseService; - private readonly IMemoryCache _cache; - public const string CacheKey = "recommendation-"; + private readonly IEasyCachingProvider _cacheProvider; + public const string CacheKey = "recommendation_"; public RecommendedController(IUnitOfWork unitOfWork, IRecommendationService recommendationService, - ILicenseService licenseService, IMemoryCache cache) + ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory) { _unitOfWork = unitOfWork; _recommendationService = recommendationService; _licenseService = licenseService; - _cache = cache; + _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations); } /// @@ -53,16 +54,14 @@ public class RecommendedController : BaseApiController } var cacheKey = $"{CacheKey}-{seriesId}-{userId}"; - if (_cache.TryGetValue(cacheKey, out string cachedData)) + var results = await _cacheProvider.GetAsync(cacheKey); + if (results.HasValue) { - return Ok(JsonConvert.DeserializeObject(cachedData)); + return Ok(results.Value); } var ret = await _recommendationService.GetRecommendationsForSeries(userId, seriesId); - var cacheEntryOptions = new MemoryCacheEntryOptions() - .SetSize(ret.OwnedSeries.Count() + ret.ExternalSeries.Count()) - .SetAbsoluteExpiration(TimeSpan.FromHours(10)); - _cache.Set(cacheKey, JsonConvert.SerializeObject(ret), cacheEntryOptions); + await _cacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromHours(10)); return Ok(ret); } diff --git a/API/Controllers/ReviewController.cs b/API/Controllers/ReviewController.cs index 9d176600c..938e5fc77 100644 --- a/API/Controllers/ReviewController.cs +++ b/API/Controllers/ReviewController.cs @@ -11,6 +11,7 @@ using API.Helpers.Builders; using API.Services; using API.Services.Plus; using AutoMapper; +using EasyCaching.Core; using Hangfire; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; @@ -26,20 +27,22 @@ public class ReviewController : BaseApiController private readonly ILicenseService _licenseService; private readonly IMapper _mapper; private readonly IReviewService _reviewService; - private readonly IMemoryCache _cache; private readonly IScrobblingService _scrobblingService; - public const string CacheKey = "review-"; + private readonly IEasyCachingProvider _cacheProvider; + public const string CacheKey = "review_"; public ReviewController(ILogger logger, IUnitOfWork unitOfWork, ILicenseService licenseService, - IMapper mapper, IReviewService reviewService, IMemoryCache cache, IScrobblingService scrobblingService) + IMapper mapper, IReviewService reviewService, IScrobblingService scrobblingService, + IEasyCachingProviderFactory cachingProviderFactory) { _logger = logger; _unitOfWork = unitOfWork; _licenseService = licenseService; _mapper = mapper; _reviewService = reviewService; - _cache = cache; _scrobblingService = scrobblingService; + + _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews); } @@ -63,15 +66,26 @@ public class ReviewController : BaseApiController var cacheKey = CacheKey + seriesId; IEnumerable externalReviews; var setCache = false; - if (_cache.TryGetValue(cacheKey, out string cachedData)) + + var result = await _cacheProvider.GetAsync>(cacheKey); + if (result.HasValue) { - externalReviews = JsonConvert.DeserializeObject>(cachedData); + externalReviews = result.Value; } else { externalReviews = await _reviewService.GetReviewsForSeries(userId, seriesId); setCache = true; } + // if (_cache.TryGetValue(cacheKey, out string cachedData)) + // { + // externalReviews = JsonConvert.DeserializeObject>(cachedData); + // } + // else + // { + // externalReviews = await _reviewService.GetReviewsForSeries(userId, seriesId); + // setCache = true; + // } // Fetch external reviews and splice them in foreach (var r in externalReviews) @@ -81,10 +95,11 @@ public class ReviewController : BaseApiController if (setCache) { - var cacheEntryOptions = new MemoryCacheEntryOptions() - .SetSize(userRatings.Count) - .SetAbsoluteExpiration(TimeSpan.FromHours(10)); - _cache.Set(cacheKey, JsonConvert.SerializeObject(externalReviews), cacheEntryOptions); + // var cacheEntryOptions = new MemoryCacheEntryOptions() + // .SetSize(userRatings.Count) + // .SetAbsoluteExpiration(TimeSpan.FromHours(10)); + //_cache.Set(cacheKey, JsonConvert.SerializeObject(externalReviews), cacheEntryOptions); + await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10)); _logger.LogDebug("Caching external reviews for {Key}", cacheKey); } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 1df1edf09..25336ec61 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Constants; @@ -15,12 +14,11 @@ using API.Extensions; using API.Helpers; using API.Services; using API.Services.Plus; -using Kavita.Common; +using EasyCaching.Core; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -31,19 +29,25 @@ public class SeriesController : BaseApiController private readonly ITaskScheduler _taskScheduler; private readonly IUnitOfWork _unitOfWork; private readonly ISeriesService _seriesService; - private readonly IMemoryCache _cache; private readonly ILicenseService _licenseService; + private readonly IEasyCachingProvider _ratingCacheProvider; + private readonly IEasyCachingProvider _reviewCacheProvider; + private readonly IEasyCachingProvider _recommendationCacheProvider; public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, - ISeriesService seriesService, IMemoryCache cache, ILicenseService licenseService) + ISeriesService seriesService, ILicenseService licenseService, + IEasyCachingProviderFactory cachingProviderFactory) { _logger = logger; _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; _seriesService = seriesService; - _cache = cache; _licenseService = licenseService; + + _ratingCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings); + _reviewCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews); + _recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations); } [HttpPost] @@ -344,12 +348,13 @@ public class SeriesController : BaseApiController if (await _licenseService.HasActiveLicense()) { _logger.LogDebug("Clearing cache as series weblinks may have changed"); - _cache.Remove(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId); - _cache.Remove(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId); + await _reviewCacheProvider.RemoveAsync(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId); + await _ratingCacheProvider.RemoveAsync(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId); + var allUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(s => s.Id); foreach (var userId in allUsers) { - _cache.Remove(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}"); + await _recommendationCacheProvider.RemoveAsync(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}"); } } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 92b8b8a1f..03b8689f8 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Jobs; using API.DTOs.MediaErrors; @@ -13,6 +14,7 @@ using API.Extensions; using API.Helpers; using API.Services; using API.Services.Tasks; +using EasyCaching.Core; using Hangfire; using Hangfire.Storage; using Kavita.Common; @@ -38,12 +40,12 @@ public class ServerController : BaseApiController private readonly IAccountService _accountService; private readonly ITaskScheduler _taskScheduler; private readonly IUnitOfWork _unitOfWork; - private readonly IMemoryCache _memoryCache; + private readonly IEasyCachingProviderFactory _cachingProviderFactory; public ServerController(ILogger logger, IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService, IScannerService scannerService, IAccountService accountService, - ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IMemoryCache memoryCache) + ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEasyCachingProviderFactory cachingProviderFactory) { _logger = logger; _backupService = backupService; @@ -55,7 +57,7 @@ public class ServerController : BaseApiController _accountService = accountService; _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; - _memoryCache = memoryCache; + _cachingProviderFactory = cachingProviderFactory; } /// @@ -244,10 +246,16 @@ public class ServerController : BaseApiController /// [Authorize("RequireAdminRole")] [HttpPost("bust-review-and-rec-cache")] - public ActionResult BustReviewAndRecCache() + public async Task BustReviewAndRecCache() { - _memoryCache.Clear(); - return Ok(); + _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(); } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 6ad8d5462..33556e6ad 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -191,6 +191,14 @@ public class SettingsController : BaseApiController _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 (OsInfo.IsDocker) continue; diff --git a/API/DTOs/Device/SendSeriesToDeviceDto.cs b/API/DTOs/Device/SendSeriesToDeviceDto.cs new file mode 100644 index 000000000..a0a907464 --- /dev/null +++ b/API/DTOs/Device/SendSeriesToDeviceDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Device; + +public class SendSeriesToDeviceDto +{ + public int DeviceId { get; set; } + public int SeriesId { get; set; } +} diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index c7d81b5a0..1a33d7175 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -72,4 +72,8 @@ public class ServerSettingDto /// The Host name (ie Reverse proxy domain name) for the server /// public string HostName { get; set; } + /// + /// The size in MB for Caching API data + /// + public long CacheSize { get; set; } } diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 58ebc04d1..28e7ed91e 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -136,7 +136,8 @@ public class AppUserProgressRepository : IAppUserProgressRepository { return await _context.AppUserProgresses .Select(d => d.LastModifiedUtc) - .MaxAsync(); + .OrderByDescending(d => d) + .FirstOrDefaultAsync(); } public async Task GetUserProgressDtoAsync(int chapterId, int userId) diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 23bfb39d3..d903496ae 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -89,10 +89,10 @@ public static class Seed }, 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 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 new() {Key = ServerSettingKey.AllowStatCollection, 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.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()}, 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()); foreach (var defaultSetting in DefaultSettings) @@ -130,7 +133,6 @@ public static class Seed directoryService.CacheDirectory + string.Empty; context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = DirectoryService.BackupDirectory + string.Empty; - await context.SaveChangesAsync(); } diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index e7f4683a4..382367186 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -128,5 +128,10 @@ public enum ServerSettingKey /// [Description("LicenseKey")] LicenseKey = 23, + /// + /// The size in MB for Caching API data + /// + [Description("Cache")] + CacheSize = 24, } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 28d4438f8..a020bc35d 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -79,6 +79,13 @@ public static class ApplicationServiceExtensions { options.UseInMemory(EasyCacheProfiles.Favicon); 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 => diff --git a/API/Extensions/MemoryCacheExtensions.cs b/API/Extensions/MemoryCacheExtensions.cs deleted file mode 100644 index 63b6afb1e..000000000 --- a/API/Extensions/MemoryCacheExtensions.cs +++ /dev/null @@ -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(); - } - } -} diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 3afc3ec4e..02bc70d06 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -70,6 +70,9 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.HostName: destination.HostName = row.Value; break; + case ServerSettingKey.CacheSize: + destination.CacheSize = long.Parse(row.Value); + break; } } diff --git a/API/Services/Plus/RatingService.cs b/API/Services/Plus/RatingService.cs index fd1fb9723..e2bb5eae3 100644 --- a/API/Services/Plus/RatingService.cs +++ b/API/Services/Plus/RatingService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -40,6 +41,9 @@ public class RatingService : IRatingService var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, 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.Empty; return await GetRatings(license.Value, series); } diff --git a/API/Services/Plus/RecommendationService.cs b/API/Services/Plus/RecommendationService.cs index 70027d22f..8da137b00 100644 --- a/API/Services/Plus/RecommendationService.cs +++ b/API/Services/Plus/RecommendationService.cs @@ -74,7 +74,7 @@ public class RecommendationService : IRecommendationService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, 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 user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 88239af46..61ab9d688 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -466,10 +466,11 @@ public class ScrobblingService : IScrobblingService var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()) .Where(l => userId == 0 || userId == l.Id) .Select(u => u.Id); + + if (!await _licenseService.HasActiveLicense()) return; + foreach (var uId in userIds) { - if (!await _licenseService.HasActiveLicense()) continue; - var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId); foreach (var wtr in wantToRead) { @@ -505,6 +506,7 @@ public class ScrobblingService : IScrobblingService foreach (var series in seriesWithProgress) { + if (!libAllowsScrobbling[series.LibraryId]) continue; await ScrobbleReadingUpdate(uId, series.Id); } @@ -687,7 +689,10 @@ public class ScrobblingService : IScrobblingService _logger.LogDebug("Processing Reading Events: {Count} / {Total}", progressCounter, totalProgress); progressCounter++; // 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); if (count == 0) { diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 3eeee1c18..54f42804c 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -3,5 +3,5 @@ "Port": 5000, "IpAddresses": "", "BaseUrl": "/", - "Cache": 50 + "Cache": 90 } diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index f6b7d086e..0e9dff983 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -13,7 +13,7 @@ public static class Configuration public const int DefaultHttpPort = 5000; public const int DefaultTimeOutSecs = 90; public const string DefaultXFrameOptions = "SAMEORIGIN"; - public const int DefaultCacheMemory = 50; + public const long DefaultCacheMemory = 75; private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); public static string KavitaPlusApiUrl = "https://plus.kavitareader.com"; @@ -42,7 +42,7 @@ public static class Configuration set => SetBaseUrl(GetAppSettingFilename(), value); } - public static int CacheSize + public static long CacheSize { get => GetCacheSize(GetAppSettingFilename()); set => SetCacheSize(GetAppSettingFilename(), value); @@ -69,15 +69,8 @@ public static class Configuration try { var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - const string key = "TokenKey"; - - if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) - { - return tokenElement.GetString(); - } - - return string.Empty; + var jsonObj = JsonSerializer.Deserialize(json); + return jsonObj.TokenKey; } catch (Exception ex) { @@ -144,29 +137,23 @@ public static class Configuration private static int GetPort(string filePath) { - const int defaultPort = 5000; if (OsInfo.IsDocker) { - return defaultPort; + return DefaultHttpPort; } try { var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - const string key = "Port"; - - if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) - { - return tokenElement.GetInt32(); - } + var jsonObj = JsonSerializer.Deserialize(json); + return jsonObj.Port; } catch (Exception ex) { Console.WriteLine("Error writing app settings: " + ex.Message); } - return defaultPort; + return DefaultHttpPort; } #endregion @@ -204,13 +191,8 @@ public static class Configuration try { var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - const string key = "IpAddresses"; - - if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) - { - return tokenElement.GetString(); - } + var jsonObj = JsonSerializer.Deserialize(json); + return jsonObj.IpAddresses; } catch (Exception ex) { @@ -224,29 +206,23 @@ public static class Configuration #region BaseUrl private static string GetBaseUrl(string filePath) { - try { var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - const string key = "BaseUrl"; + var jsonObj = JsonSerializer.Deserialize(json); - if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) + var baseUrl = jsonObj.BaseUrl; + if (!string.IsNullOrEmpty(baseUrl)) { - var baseUrl = tokenElement.GetString(); - if (!string.IsNullOrEmpty(baseUrl)) - { - baseUrl = !baseUrl.StartsWith('/') - ? $"/{baseUrl}" - : baseUrl; + baseUrl = !baseUrl.StartsWith('/') + ? $"/{baseUrl}" + : baseUrl; - baseUrl = !baseUrl.EndsWith('/') - ? $"{baseUrl}/" - : baseUrl; + baseUrl = !baseUrl.EndsWith('/') + ? $"{baseUrl}/" + : baseUrl; - return baseUrl; - } - return DefaultBaseUrl; + return baseUrl; } } catch (Exception ex) @@ -284,7 +260,7 @@ public static class Configuration #endregion #region CacheSize - private static void SetCacheSize(string filePath, int cache) + private static void SetCacheSize(string filePath, long cache) { if (cache <= 0) return; try @@ -301,18 +277,14 @@ public static class Configuration } } - private static int GetCacheSize(string filePath) + private static long GetCacheSize(string filePath) { try { var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - const string key = "Port"; + var jsonObj = JsonSerializer.Deserialize(json); - if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) - { - return tokenElement.GetInt32(); - } + return jsonObj.Cache; } catch (Exception ex) { @@ -336,14 +308,8 @@ public static class Configuration try { var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - const string key = "XFrameOrigins"; - - if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) - { - var origins = tokenElement.GetString(); - return !string.IsNullOrEmpty(origins) ? origins : DefaultBaseUrl; - } + var jsonObj = JsonSerializer.Deserialize(json); + return !string.IsNullOrEmpty(jsonObj.XFrameOrigins) ? jsonObj.XFrameOrigins : DefaultXFrameOptions; } catch (Exception ex) { @@ -364,6 +330,8 @@ public static class Configuration // ReSharper disable once MemberHidesStaticFromOuterClass public string BaseUrl { get; set; } // ReSharper disable once MemberHidesStaticFromOuterClass - public int Cache { get; set; } + public long Cache { get; set; } + // ReSharper disable once MemberHidesStaticFromOuterClass + public string XFrameOrigins { get; set; } = DefaultXFrameOptions; } } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index a5c896334..0f1128f16 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -40,7 +40,7 @@ export class ActionService implements OnDestroy { private readingListModalRef: NgbModalRef | null = null; private collectionModalRef: NgbModalRef | null = null; - constructor(private libraryService: LibraryService, private seriesService: SeriesService, + constructor(private libraryService: LibraryService, private seriesService: SeriesService, private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, private confirmService: ConfirmService, private memberService: MemberService, private deviceSerivce: DeviceService) { } @@ -53,7 +53,7 @@ export class ActionService implements OnDestroy { * Request a file scan for a given Library * @param library Partial Library, must have id and name populated * @param callback Optional callback to perform actions after API completes - * @returns + * @returns */ async scanLibrary(library: Partial, callback?: LibraryActionCallback) { if (!library.hasOwnProperty('id') || library.id === undefined) { @@ -76,7 +76,7 @@ export class ActionService implements OnDestroy { * Request a refresh of Metadata for a given Library * @param library Partial Library, must have id and name populated * @param callback Optional callback to perform actions after API completes - * @returns + * @returns */ async refreshMetadata(library: Partial, callback?: LibraryActionCallback) { if (!library.hasOwnProperty('id') || library.id === undefined) { @@ -112,7 +112,7 @@ export class ActionService implements OnDestroy { * Request an analysis of files for a given Library (currently just word count) * @param library Partial Library, must have id and name populated * @param callback Optional callback to perform actions after API completes - * @returns + * @returns */ async analyzeFiles(library: Partial, callback?: LibraryActionCallback) { if (!library.hasOwnProperty('id') || library.id === undefined) { @@ -285,7 +285,7 @@ export class ActionService implements OnDestroy { * @param seriesId Series Id * @param volumes Volumes, should have id, chapters and pagesRead populated * @param chapters? Chapters, should have id - * @param callback Optional callback to perform actions after API completes + * @param callback Optional callback to perform actions after API completes */ markMultipleAsRead(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { this.readerService.markMultipleRead(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => { @@ -306,7 +306,7 @@ export class ActionService implements OnDestroy { * Mark all chapters and the volumes as Unread. All volumes must belong to a series * @param seriesId Series Id * @param volumes Volumes, should have id, chapters and pagesRead populated - * @param callback Optional callback to perform actions after API completes + * @param callback Optional callback to perform actions after API completes */ markMultipleAsUnread(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { this.readerService.markMultipleUnread(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => { @@ -326,7 +326,7 @@ export class ActionService implements OnDestroy { /** * Mark all series as Read. * @param series Series, should have id, pagesRead populated - * @param callback Optional callback to perform actions after API completes + * @param callback Optional callback to perform actions after API completes */ markMultipleSeriesAsRead(series: Array, callback?: VoidActionCallback) { this.readerService.markMultipleSeriesRead(series.map(v => v.id)).pipe(take(1)).subscribe(() => { @@ -342,9 +342,9 @@ export class ActionService implements OnDestroy { } /** - * Mark all series as Unread. + * Mark all series as Unread. * @param series Series, should have id, pagesRead populated - * @param callback Optional callback to perform actions after API completes + * @param callback Optional callback to perform actions after API completes */ markMultipleSeriesAsUnread(series: Array, callback?: VoidActionCallback) { this.readerService.markMultipleSeriesUnread(series.map(v => v.id)).pipe(take(1)).subscribe(() => { @@ -425,9 +425,9 @@ export class ActionService implements OnDestroy { /** * Adds a set of series to a collection tag - * @param series - * @param callback - * @returns + * @param series + * @param callback + * @returns */ addMultipleSeriesToCollectionTag(series: Array, callback?: BooleanActionCallback) { if (this.collectionModalRef != null) { return; } @@ -452,7 +452,7 @@ export class ActionService implements OnDestroy { addSeriesToReadingList(series: Series, callback?: SeriesActionCallback) { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); - this.readingListModalRef.componentInstance.seriesId = series.id; + this.readingListModalRef.componentInstance.seriesId = series.id; this.readingListModalRef.componentInstance.title = series.name; this.readingListModalRef.componentInstance.type = ADD_FLOW.Series; @@ -474,7 +474,7 @@ export class ActionService implements OnDestroy { addVolumeToReadingList(volume: Volume, seriesId: number, callback?: VolumeActionCallback) { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); - this.readingListModalRef.componentInstance.seriesId = seriesId; + this.readingListModalRef.componentInstance.seriesId = seriesId; this.readingListModalRef.componentInstance.volumeId = volume.id; this.readingListModalRef.componentInstance.type = ADD_FLOW.Volume; @@ -496,7 +496,7 @@ export class ActionService implements OnDestroy { addChapterToReadingList(chapter: Chapter, seriesId: number, callback?: ChapterActionCallback) { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); - this.readingListModalRef.componentInstance.seriesId = seriesId; + this.readingListModalRef.componentInstance.seriesId = seriesId; this.readingListModalRef.componentInstance.chapterId = chapter.id; this.readingListModalRef.componentInstance.type = ADD_FLOW.Chapter; @@ -517,7 +517,7 @@ export class ActionService implements OnDestroy { editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) { const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'lg' }); - readingListModalRef.componentInstance.readingList = readingList; + readingListModalRef.componentInstance.readingList = readingList; readingListModalRef.closed.pipe(take(1)).subscribe((list) => { if (callback && list !== undefined) { callback(readingList); @@ -535,7 +535,7 @@ export class ActionService implements OnDestroy { * @param seriesId Series Id * @param volumes Volumes, should have id, chapters and pagesRead populated * @param chapters? Chapters, should have id - * @param callback Optional callback to perform actions after API completes + * @param callback Optional callback to perform actions after API completes */ async deleteMultipleSeries(seriesIds: Array, callback?: BooleanActionCallback) { if (!await this.confirmService.confirm('Are you sure you want to delete ' + seriesIds.length + ' series? It will not modify files on disk.')) { @@ -578,15 +578,13 @@ export class ActionService implements OnDestroy { }); } - private async promptIfForce(extraContent: string = '') { - // Prompt user if we should do a force or not - const config = this.confirmService.defaultConfirm; - config.header = 'Force Scan'; - config.buttons = [ - {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 + sendSeriesToDevice(seriesId: number, device: Device, callback?: VoidActionCallback) { + this.deviceSerivce.sendSeriesTo(seriesId, device.id).subscribe(() => { + this.toastr.success('File(s) emailed to ' + device.name); + if (callback) { + callback(); + } + }); } + } diff --git a/UI/Web/src/app/_services/device.service.ts b/UI/Web/src/app/_services/device.service.ts index c7b062cc6..1ba491177 100644 --- a/UI/Web/src/app/_services/device.service.ts +++ b/UI/Web/src/app/_services/device.service.ts @@ -19,7 +19,7 @@ export class DeviceService { constructor(private httpClient: HttpClient, private accountService: AccountService) { - // Ensure we are authenticated before we make an authenticated api call. + // Ensure we are authenticated before we make an authenticated api call. this.accountService.currentUser$.subscribe(user => { if (!user) { this.devicesSource.next([]); @@ -54,5 +54,9 @@ export class DeviceService { 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); + } + + } diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index a0b6667d7..60147c966 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -17,4 +17,5 @@ export interface ServerSettings { totalLogs: number; enableFolderWatching: boolean; hostName: string; + cacheSize: number; } diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.html b/UI/Web/src/app/admin/dashboard/dashboard.component.html index 97ca4fbef..72dcdd961 100644 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.html +++ b/UI/Web/src/app/admin/dashboard/dashboard.component.html @@ -36,7 +36,7 @@ -

Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock premium benefits today!

+

Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock premium benefits today! FAQ

diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index a20f13659..ae15a31b2 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -1,7 +1,7 @@
@@ -97,13 +97,32 @@
-
- -

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 wiki for what is collected.

-
- - -
+
+
+   + The amount of memory allowed for caching heavy APIs. Default is 50MB. + The amount of memory allowed for caching heavy APIs. Default is 50MB. + + +

+ You must have at least 50 MB. +

+

+ This field is required +

+
+
+
+ +
+ +

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 wiki for what is collected.

+
+ + +
diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts index 8308e8e4f..47a889ced 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts @@ -52,6 +52,7 @@ export class ManageSettingsComponent implements OnInit { 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('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('enableFolderWatching', new FormControl(this.serverSettings.enableFolderWatching, [Validators.required])); 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('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs); this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName); + this.settingsForm.get('cacheSize')?.setValue(this.serverSettings.cacheSize); this.settingsForm.markAsPristine(); } @@ -127,5 +129,5 @@ export class ManageSettingsComponent implements OnInit { }); } - + } diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss index c4aa6459a..f9d45420e 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss @@ -56,44 +56,44 @@ } .btn { - text-decoration: none; - color: hsla(0,0%,100%,.7); - height: 25px; - text-align: center; - -webkit-tap-highlight-color: transparent; - background: none; - border: 0; - border-radius: 0; - cursor: pointer; - line-height: inherit; - margin: 0; - outline: none; - padding: 0; - text-align: inherit; - text-decoration: none; - touch-action: manipulation; - transition: color .2s; - -webkit-user-select: none; - user-select: none; + text-decoration: none; + color: hsla(0,0%,100%,.7); + height: 25px; + text-align: center; + padding: 0px 5px; + -webkit-tap-highlight-color: transparent; + background: none; + border: 0; + border-radius: 0; + cursor: pointer; + line-height: inherit; + margin: 0; + outline: none; + text-align: inherit; + text-decoration: none; + touch-action: manipulation; + transition: color .2s; + -webkit-user-select: none; + user-select: none; - &:hover { - color: var(--primary-color); - } + &:hover { + color: var(--primary-color); + } - .active { - font-weight: bold; - } + .active { + font-weight: bold; + } - &.disabled { - color: lightgrey; - cursor: not-allowed; - } - } + &.disabled { + color: lightgrey; + cursor: not-allowed; + } + } } .virtual-scroller, virtual-scroller { width: 100%; - //height: calc(100vh - 160px); // 64 is a random number, 523 for me. + //height: calc(100vh - 160px); // 64 is a random number, 523 for me. height: calc(var(--vh) * 100 - 173px); //height: calc(100vh - 160px); //background-color: red; @@ -107,4 +107,4 @@ virtual-scroller.empty { h2 { display: inline-block; word-break: break-all; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index d641e044a..41b9102a7 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -21,6 +21,7 @@ import { RelationKind } from 'src/app/_models/series-detail/relation-kind'; import {CommonModule} from "@angular/common"; import {CardItemComponent} from "../card-item/card-item.component"; import {RelationshipPipe} from "../../pipe/relationship.pipe"; +import {Device} from "../../_models/device/device"; @Component({ selector: 'app-series-card', @@ -120,6 +121,10 @@ export class SeriesCardComponent implements OnInit, OnChanges { case (Action.AnalyzeFiles): this.actionService.analyzeFilesForSeries(series); break; + case Action.SendTo: + const device = (action._extra!.data as Device); + this.actionService.sendSeriesToDevice(series.id, device); + break; default: break; } diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index 666b7d9bd..4c6e8909f 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -5,13 +5,11 @@ import { EventEmitter, HostListener, inject, - OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; -import { Subject } from 'rxjs'; -import { debounceTime, take, takeUntil } from 'rxjs/operators'; +import { take } from 'rxjs/operators'; import { BulkSelectionService } from '../cards/bulk-selection.service'; import { KEY_CODES, UtilityService } from '../shared/_services/utility.service'; 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 { 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 {Device} from "../_models/device/device"; @Component({ selector: 'app-library-detail', @@ -234,6 +233,8 @@ export class LibraryDetailComponent implements OnInit { } } + + performAction(action: ActionItem) { if (typeof action.callback === 'function') { action.callback(action, undefined); diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index 83fa4e8b9..5fad56bf6 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -329,14 +329,14 @@
- +
- +
diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.scss b/UI/Web/src/app/metadata-filter/metadata-filter.component.scss index e69de29bb..1fff207f7 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.scss +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.scss @@ -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; +} + diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index 8576290ec..de4474168 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -137,8 +137,8 @@ export class MetadataFilterComponent implements OnInit { }); this.releaseYearRange = new FormGroup({ - min: 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)]) + 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), Validators.maxLength(4), Validators.minLength(4)]) }); this.readProgressGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => { diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.ts b/UI/Web/src/app/user-settings/change-email/change-email.component.ts index addf35505..a58de0ff1 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.ts +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.ts @@ -22,8 +22,6 @@ export class ChangeEmailComponent implements OnInit { form: FormGroup = new FormGroup({}); user: User | undefined = undefined; - hasChangePasswordAbility: Observable = of(false); - passwordsMatch = false; errors: string[] = []; isViewMode: boolean = true; emailLink: string = ''; diff --git a/UI/Web/src/theme/components/_nav.scss b/UI/Web/src/theme/components/_nav.scss index f2e93c814..a09041700 100644 --- a/UI/Web/src/theme/components/_nav.scss +++ b/UI/Web/src/theme/components/_nav.scss @@ -1,3 +1,7 @@ +.nav { + --bs-nav-link-disabled-color: rgb(154 187 219 / 75%); +} + .nav-link { color: var(--nav-link-text-color); @@ -19,11 +23,11 @@ .nav-tabs { border-color: var(--nav-tab-border-color); - + .nav-link { color: var(--nav-link-text-color); position: relative; - + &.active, &:focus { color: var(--nav-tab-active-text-color); background-color: var(--nav-tab-bg-color); @@ -37,7 +41,7 @@ &.active::before { transform: scaleY(1); } - + &:hover { color: var(--nav-tab-hover-text-color); background-color: var(--nav-tab-hover-bg-color); diff --git a/openapi.json b/openapi.json index 6b3da3b06..6fcfd6a94 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.4.0" + "version": "0.7.4.1" }, "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": { "get": { "tags": [ @@ -14650,6 +14681,20 @@ "additionalProperties": false, "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": { "type": "object", "properties": { @@ -15782,6 +15827,11 @@ "type": "string", "description": "The Host name (ie Reverse proxy domain name) for the server", "nullable": true + }, + "cacheSize": { + "type": "integer", + "description": "The size in MB for Caching API data", + "format": "int64" } }, "additionalProperties": false