diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs
index 886643893..69bfdf0bb 100644
--- a/API.Tests/Parser/ParserTest.cs
+++ b/API.Tests/Parser/ParserTest.cs
@@ -225,6 +225,7 @@ public class ParserTests
[InlineData("@Recently-Snapshot/Love Hina/", true)]
[InlineData("@recycle/Love Hina/", true)]
[InlineData("E:/Test/__MACOSX/Love Hina/", true)]
+ [InlineData("E:/Test/.caltrash/Love Hina/", true)]
public void HasBlacklistedFolderInPathTest(string inputPath, bool expected)
{
Assert.Equal(expected, HasBlacklistedFolderInPath(inputPath));
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/Constants/ResponseCacheProfiles.cs b/API/Constants/ResponseCacheProfiles.cs
index 1a092d84e..d7dcaf95b 100644
--- a/API/Constants/ResponseCacheProfiles.cs
+++ b/API/Constants/ResponseCacheProfiles.cs
@@ -16,5 +16,5 @@ public static class ResponseCacheProfiles
public const string Instant = "Instant";
public const string Month = "Month";
public const string LicenseCache = "LicenseCache";
- public const string Recommendation = "Recommendation";
+ public const string KavitaPlus = "KavitaPlus";
}
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/LicenseController.cs b/API/Controllers/LicenseController.cs
index b00f6d07f..e0280f78c 100644
--- a/API/Controllers/LicenseController.cs
+++ b/API/Controllers/LicenseController.cs
@@ -65,7 +65,7 @@ public class LicenseController : BaseApiController
}
///
- /// Updates server license. Returns true if updated and valid
+ /// Updates server license
///
/// Caches the result
///
diff --git a/API/Controllers/RatingController.cs b/API/Controllers/RatingController.cs
index 22bc18c91..4b816bab7 100644
--- a/API/Controllers/RatingController.cs
+++ b/API/Controllers/RatingController.cs
@@ -1,15 +1,14 @@
using System;
-using System.Collections;
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using API.Constants;
+using API.Data;
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 +19,20 @@ 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 IUnitOfWork _unitOfWork;
+ 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, IUnitOfWork unitOfWork)
{
_licenseService = licenseService;
_ratingService = ratingService;
- _cache = memoryCache;
_logger = logger;
+ _unitOfWork = unitOfWork;
+
+ _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
}
///
@@ -38,36 +41,36 @@ public class RatingController : BaseApiController
///
///
[HttpGet]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.Recommendation, VaryByQueryKeys = new []{"seriesId"})]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
public async Task>> GetRating(int seriesId)
{
+
if (!await _licenseService.HasActiveLicense())
{
- return Ok(new List());
+ return Ok(Enumerable.Empty());
}
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);
}
+
+ [HttpGet("overall")]
+ public async Task> GetOverallRating(int seriesId)
+ {
+ return Ok(new RatingDto()
+ {
+ Provider = ScrobbleProvider.Kavita,
+ AverageScore = await _unitOfWork.SeriesRepository.GetAverageUserRating(seriesId),
+ FavoriteCount = 0
+ });
+ }
}
diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs
index ffa650a73..356592dfd 100644
--- a/API/Controllers/ReaderController.cs
+++ b/API/Controllers/ReaderController.cs
@@ -314,6 +314,7 @@ public class ReaderController : BaseApiController
if (!await _unitOfWork.CommitAsync()) return BadRequest("There was an issue saving progress");
BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markReadDto.SeriesId));
+ BackgroundJob.Enqueue(() => _unitOfWork.SeriesRepository.ClearOnDeckRemoval(markReadDto.SeriesId, user.Id));
return Ok();
}
@@ -376,13 +377,11 @@ public class ReaderController : BaseApiController
MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, markVolumeReadDto.SeriesId,
markVolumeReadDto.VolumeId, 0, chapters.Sum(c => c.Pages)));
- if (await _unitOfWork.CommitAsync())
- {
- BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markVolumeReadDto.SeriesId));
- return Ok();
- }
+ if (!await _unitOfWork.CommitAsync()) return BadRequest("Could not save progress");
- return BadRequest("Could not save progress");
+ BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markVolumeReadDto.SeriesId));
+ BackgroundJob.Enqueue(() => _unitOfWork.SeriesRepository.ClearOnDeckRemoval(markVolumeReadDto.SeriesId, user.Id));
+ return Ok();
}
@@ -406,14 +405,12 @@ public class ReaderController : BaseApiController
var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds);
await _readerService.MarkChaptersAsRead(user, dto.SeriesId, chapters.ToList());
- if (await _unitOfWork.CommitAsync())
- {
- BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, dto.SeriesId));
- return Ok();
- }
+ if (!await _unitOfWork.CommitAsync()) return BadRequest("Could not save progress");
+ BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, dto.SeriesId));
+ BackgroundJob.Enqueue(() => _unitOfWork.SeriesRepository.ClearOnDeckRemoval(dto.SeriesId, user.Id));
+ return Ok();
- return BadRequest("Could not save progress");
}
///
@@ -463,16 +460,14 @@ public class ReaderController : BaseApiController
await _readerService.MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters);
}
- if (await _unitOfWork.CommitAsync())
- {
- foreach (var sId in dto.SeriesIds)
- {
- BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, sId));
- }
- return Ok();
- }
+ if (!await _unitOfWork.CommitAsync()) return BadRequest("Could not save progress");
- return BadRequest("Could not save progress");
+ foreach (var sId in dto.SeriesIds)
+ {
+ BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, sId));
+ BackgroundJob.Enqueue(() => _unitOfWork.SeriesRepository.ClearOnDeckRemoval(sId, user.Id));
+ }
+ return Ok();
}
///
@@ -530,11 +525,14 @@ public class ReaderController : BaseApiController
///
///
[HttpPost("progress")]
- public async Task BookmarkProgress(ProgressDto progressDto)
+ public async Task SaveProgress(ProgressDto progressDto)
{
- if (await _readerService.SaveReadingProgress(progressDto, User.GetUserId())) return Ok(true);
+ var userId = User.GetUserId();
+ if (!await _readerService.SaveReadingProgress(progressDto, userId))
+ return BadRequest("Could not save progress");
- return BadRequest("Could not save progress");
+
+ return Ok(true);
}
///
@@ -545,9 +543,7 @@ public class ReaderController : BaseApiController
[HttpGet("continue-point")]
public async Task> GetContinuePoint(int seriesId)
{
- var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
-
- return Ok(await _readerService.GetContinuePoint(seriesId, userId));
+ return Ok(await _readerService.GetContinuePoint(seriesId, User.GetUserId()));
}
///
@@ -558,8 +554,7 @@ public class ReaderController : BaseApiController
[HttpGet("has-progress")]
public async Task> HasProgress(int seriesId)
{
- var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
- return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId));
+ return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, User.GetUserId()));
}
///
@@ -570,10 +565,7 @@ public class ReaderController : BaseApiController
[HttpGet("chapter-bookmarks")]
public async Task>> GetBookmarks(int chapterId)
{
- var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
- if (user == null) return Unauthorized();
- if (user.Bookmarks == null) return Ok(Array.Empty());
- return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId));
+ return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(User.GetUserId(), chapterId));
}
///
@@ -584,11 +576,7 @@ public class ReaderController : BaseApiController
[HttpPost("all-bookmarks")]
public async Task>> GetAllBookmarks(FilterDto filterDto)
{
- var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
- if (user == null) return Unauthorized();
- if (user.Bookmarks == null) return Ok(Array.Empty());
-
- return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id, filterDto));
+ return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(User.GetUserId(), filterDto));
}
///
@@ -676,10 +664,7 @@ public class ReaderController : BaseApiController
[HttpGet("volume-bookmarks")]
public async Task>> GetBookmarksForVolume(int volumeId)
{
- var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
- if (user == null) return Unauthorized();
- if (user.Bookmarks == null) return Ok(Array.Empty());
- return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId));
+ return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(User.GetUserId(), volumeId));
}
///
@@ -690,11 +675,7 @@ public class ReaderController : BaseApiController
[HttpGet("series-bookmarks")]
public async Task>> GetBookmarksForSeries(int seriesId)
{
- var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
- if (user == null) return Unauthorized();
- if (user.Bookmarks == null) return Ok(Array.Empty());
-
- return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId));
+ return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(User.GetUserId(), seriesId));
}
///
@@ -760,8 +741,7 @@ public class ReaderController : BaseApiController
[HttpGet("next-chapter")]
public async Task> GetNextChapter(int seriesId, int volumeId, int currentChapterId)
{
- var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
- return await _readerService.GetNextChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
+ return await _readerService.GetNextChapterIdAsync(seriesId, volumeId, currentChapterId, User.GetUserId());
}
@@ -779,8 +759,7 @@ public class ReaderController : BaseApiController
[HttpGet("prev-chapter")]
public async Task> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId)
{
- var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
- return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
+ return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, User.GetUserId());
}
///
@@ -793,7 +772,7 @@ public class ReaderController : BaseApiController
[ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId"})]
public async Task> GetEstimateToCompletion(int seriesId)
{
- var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
+ var userId = User.GetUserId();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
// Get all sum of all chapters with progress that is complete then subtract from series. Multiply by modifiers
diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs
index 31146990e..efadb6d60 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);
}
///
@@ -38,7 +39,7 @@ public class RecommendedController : BaseApiController
///
///
[HttpGet("recommendations")]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.Recommendation, VaryByQueryKeys = new []{"seriesId"})]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
public async Task> GetRecommendations(int seriesId)
{
var userId = User.GetUserId();
@@ -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..27508f1ce 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);
}
@@ -48,7 +51,7 @@ public class ReviewController : BaseApiController
///
///
[HttpGet]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.Recommendation, VaryByQueryKeys = new []{"seriesId"})]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
public async Task>> GetReviews(int seriesId)
{
var userId = User.GetUserId();
@@ -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..6c3b7ced8 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]
@@ -280,6 +284,18 @@ public class SeriesController : BaseApiController
return Ok(pagedList);
}
+ ///
+ /// Removes a series from displaying on deck until the next read event on that series
+ ///
+ ///
+ ///
+ [HttpPost("remove-from-on-deck")]
+ public async Task RemoveFromOnDeck([FromQuery] int seriesId)
+ {
+ await _unitOfWork.SeriesRepository.RemoveFromOnDeck(seriesId, User.GetUserId());
+ return Ok();
+ }
+
///
/// Runs a Cover Image Generation task
///
@@ -344,12 +360,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..3417d9732 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;
}
///
@@ -122,6 +124,17 @@ public class ServerController : BaseApiController
return Ok(await _statsService.GetServerInfo());
}
+ ///
+ /// Returns non-sensitive information about the current system
+ ///
+ /// This is just for the UI and is extremly lightweight
+ ///
+ [HttpGet("server-info-slim")]
+ public async Task> GetSlimVersion()
+ {
+ return Ok(await _statsService.GetServerInfoSlim());
+ }
+
///
/// Triggers the scheduling of the convert media job. This will convert all media to the target encoding (except for PNG). Only one job will run at a time.
@@ -239,14 +252,20 @@ public class ServerController : BaseApiController
///
- /// Bust Review and Recommendation Cache
+ /// Bust Kavita+ Cache
///
///
[Authorize("RequireAdminRole")]
[HttpPost("bust-review-and-rec-cache")]
- public ActionResult BustReviewAndRecCache()
+ public async Task 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();
}
diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs
index 6ad8d5462..9466fe6dd 100644
--- a/API/Controllers/SettingsController.cs
+++ b/API/Controllers/SettingsController.cs
@@ -182,6 +182,24 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting);
}
+ if (setting.Key == ServerSettingKey.OnDeckProgressDays && updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.OnDeckUpdateDays && updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value)
+ {
+ setting.Value = updateSettingsDto.TaskScan;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value)
{
if (OsInfo.IsDocker) continue;
@@ -191,6 +209,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/ChapterDto.cs b/API/DTOs/ChapterDto.cs
index 4c37fd3eb..843dabde4 100644
--- a/API/DTOs/ChapterDto.cs
+++ b/API/DTOs/ChapterDto.cs
@@ -45,6 +45,10 @@ public class ChapterDto : IHasReadTimeEstimate, IEntityDate
///
public DateTime LastReadingProgressUtc { get; set; }
///
+ /// The last time a chapter was read by current authenticated user
+ ///
+ public DateTime LastReadingProgress { get; set; }
+ ///
/// If the Cover Image is locked for this entity
///
public bool CoverImageLocked { get; set; }
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/SeriesDetail/UserReviewDto.cs b/API/DTOs/SeriesDetail/UserReviewDto.cs
index f4f94ebd7..4f74dadbb 100644
--- a/API/DTOs/SeriesDetail/UserReviewDto.cs
+++ b/API/DTOs/SeriesDetail/UserReviewDto.cs
@@ -1,4 +1,6 @@
-namespace API.DTOs.SeriesDetail;
+using API.Services.Plus;
+
+namespace API.DTOs.SeriesDetail;
///
/// Represents a User Review for a given Series
@@ -48,4 +50,9 @@ public class UserReviewDto
/// The main body with just text, for review preview
///
public string? BodyJustText { get; set; }
+
+ ///
+ /// If this review is External, which Provider did it come from
+ ///
+ public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita;
}
diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs
index c7d81b5a0..15dd9177b 100644
--- a/API/DTOs/Settings/ServerSettingDTO.cs
+++ b/API/DTOs/Settings/ServerSettingDTO.cs
@@ -72,4 +72,16 @@ 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; }
+ ///
+ /// How many Days since today in the past for reading progress, should content be considered for On Deck, before it gets removed automatically
+ ///
+ public int OnDeckProgressDays { get; set; }
+ ///
+ /// How many Days since today in the past for chapter updates, should content be considered for On Deck, before it gets removed automatically
+ ///
+ public int OnDeckUpdateDays { get; set; }
}
diff --git a/API/DTOs/Stats/ServerInfoSlimDto.cs b/API/DTOs/Stats/ServerInfoSlimDto.cs
new file mode 100644
index 000000000..e8db6a2b0
--- /dev/null
+++ b/API/DTOs/Stats/ServerInfoSlimDto.cs
@@ -0,0 +1,21 @@
+namespace API.DTOs.Stats;
+
+///
+/// This is just for the Server tab on UI
+///
+public class ServerInfoSlimDto
+{
+ ///
+ /// Unique Id that represents a unique install
+ ///
+ public required string InstallId { get; set; }
+ ///
+ /// If the Kavita install is using Docker
+ ///
+ public bool IsDocker { get; set; }
+ ///
+ /// Version of Kavita
+ ///
+ public required string KavitaVersion { get; set; }
+
+}
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index 416ec5d05..e4dc6b746 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -52,6 +52,7 @@ public sealed class DataContext : IdentityDbContext ScrobbleEvent { get; set; } = null!;
public DbSet ScrobbleError { get; set; } = null!;
public DbSet ScrobbleHold { get; set; } = null!;
+ public DbSet AppUserOnDeckRemoval { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
diff --git a/API/Data/Migrations/20230715125951_OnDeckRemoval.Designer.cs b/API/Data/Migrations/20230715125951_OnDeckRemoval.Designer.cs
new file mode 100644
index 000000000..90035e9f0
--- /dev/null
+++ b/API/Data/Migrations/20230715125951_OnDeckRemoval.Designer.cs
@@ -0,0 +1,2184 @@
+//
+using System;
+using API.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace API.Data.Migrations
+{
+ [DbContext(typeof(DataContext))]
+ [Migration("20230715125951_OnDeckRemoval")]
+ partial class OnDeckRemoval
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "7.0.8");
+
+ modelBuilder.Entity("API.Entities.AppRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestriction")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestrictionIncludeUnknowns")
+ .HasColumnType("INTEGER");
+
+ b.Property("AniListAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("ConfirmationToken")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LastActiveUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserBookmark");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserOnDeckRemoval");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackgroundColor")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("#000000");
+
+ b.Property("BlurUnreadSummaries")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderImmersiveMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderWritingStyle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("BookThemeName")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("Dark");
+
+ b.Property("CollapseSeriesRelationships")
+ .HasColumnType("INTEGER");
+
+ b.Property("EmulateBook")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalPageLayoutMode")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("LayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("NoTransitions")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("PromptForDownloadSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShareReviews")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowScreenHints")
+ .HasColumnType("INTEGER");
+
+ b.Property("SwipeToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("ThemeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId")
+ .IsUnique();
+
+ b.HasIndex("ThemeId");
+
+ b.ToTable("AppUserPreferences");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserProgress", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PagesRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserProgresses");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("INTEGER");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Tagline")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("AlternateSeries")
+ .HasColumnType("TEXT");
+
+ b.Property("AvgHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("Count")
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ISBN")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("");
+
+ b.Property("IsSpecial")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MaxHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("MinHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("Number")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("Range")
+ .HasColumnType("TEXT");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesGroup")
+ .HasColumnType("TEXT");
+
+ b.Property("StoryArc")
+ .HasColumnType("TEXT");
+
+ b.Property("StoryArcNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("TitleName")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("WebLinks")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("");
+
+ b.Property("WordCount")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("VolumeId");
+
+ b.ToTable("Chapter");
+ });
+
+ modelBuilder.Entity("API.Entities.CollectionTag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("NormalizedTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Promoted")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Id", "Promoted")
+ .IsUnique();
+
+ b.ToTable("CollectionTag");
+ });
+
+ modelBuilder.Entity("API.Entities.Device", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EmailAddress")
+ .HasColumnType("TEXT");
+
+ b.Property("IpAddress")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastUsed")
+ .HasColumnType("TEXT");
+
+ b.Property("LastUsedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Platform")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("Device");
+ });
+
+ modelBuilder.Entity("API.Entities.FolderPath", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("LastScanned")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Path")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId");
+
+ b.ToTable("FolderPath");
+ });
+
+ modelBuilder.Entity("API.Entities.Genre", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("NormalizedTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedTitle")
+ .IsUnique();
+
+ b.ToTable("Genre");
+ });
+
+ modelBuilder.Entity("API.Entities.Library", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AllowScrobbling")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("FolderWatching")
+ .HasColumnType("INTEGER");
+
+ b.Property("IncludeInDashboard")
+ .HasColumnType("INTEGER");
+
+ b.Property("IncludeInRecommended")
+ .HasColumnType("INTEGER");
+
+ b.Property("IncludeInSearch")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastScanned")
+ .HasColumnType("TEXT");
+
+ b.Property("ManageCollections")
+ .HasColumnType("INTEGER");
+
+ b.Property("ManageReadingLists")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Library");
+ });
+
+ modelBuilder.Entity("API.Entities.MangaFile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Bytes")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Extension")
+ .HasColumnType("TEXT");
+
+ b.Property("FilePath")
+ .HasColumnType("TEXT");
+
+ b.Property("Format")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastFileAnalysis")
+ .HasColumnType("TEXT");
+
+ b.Property("LastFileAnalysisUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChapterId");
+
+ b.ToTable("MangaFile");
+ });
+
+ modelBuilder.Entity("API.Entities.MediaError", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Details")
+ .HasColumnType("TEXT");
+
+ b.Property("Extension")
+ .HasColumnType("TEXT");
+
+ b.Property("FilePath")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaError");
+ });
+
+ modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRatingLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("CharacterLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("ColoristLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverArtistLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("EditorLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("GenresLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("InkerLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasColumnType("TEXT");
+
+ b.Property