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("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", 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("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230715125951_OnDeckRemoval.cs b/API/Data/Migrations/20230715125951_OnDeckRemoval.cs new file mode 100644 index 000000000..3cc27196f --- /dev/null +++ b/API/Data/Migrations/20230715125951_OnDeckRemoval.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class OnDeckRemoval : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Title", + table: "ReadingList", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "NormalizedTitle", + table: "ReadingList", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.CreateTable( + name: "AppUserOnDeckRemoval", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SeriesId = table.Column(type: "INTEGER", nullable: false), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserOnDeckRemoval", x => x.Id); + table.ForeignKey( + name: "FK_AppUserOnDeckRemoval_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserOnDeckRemoval_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserOnDeckRemoval_AppUserId", + table: "AppUserOnDeckRemoval", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserOnDeckRemoval_SeriesId", + table: "AppUserOnDeckRemoval", + column: "SeriesId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserOnDeckRemoval"); + + migrationBuilder.AlterColumn( + name: "Title", + table: "ReadingList", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "NormalizedTitle", + table: "ReadingList", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index e4fd9dd2c..cc8d2660f 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.8"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -183,6 +183,27 @@ namespace API.Data.Migrations 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") @@ -943,6 +964,7 @@ namespace API.Data.Migrations .HasColumnType("TEXT"); b.Property("NormalizedTitle") + .IsRequired() .HasColumnType("TEXT"); b.Property("Promoted") @@ -958,6 +980,7 @@ namespace API.Data.Migrations .HasColumnType("TEXT"); b.Property("Title") + .IsRequired() .HasColumnType("TEXT"); b.HasKey("Id"); @@ -1082,7 +1105,7 @@ namespace API.Data.Migrations b.Property("LibraryId") .HasColumnType("INTEGER"); - b.Property("MalId") + b.Property("MalId") .HasColumnType("INTEGER"); b.Property("ProcessDateUtc") @@ -1626,6 +1649,25 @@ namespace API.Data.Migrations b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + modelBuilder.Entity("API.Entities.AppUserPreferences", b => { b.HasOne("API.Entities.AppUser", "AppUser") 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/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index c31e059b2..bc28b9e1b 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -253,11 +253,13 @@ public class ChapterRepository : IChapterRepository { chapter.PagesRead = progress.PagesRead ; chapter.LastReadingProgressUtc = progress.LastModifiedUtc; + chapter.LastReadingProgress = progress.LastModified; } else { chapter.PagesRead = 0; chapter.LastReadingProgressUtc = DateTime.MinValue; + chapter.LastReadingProgress = DateTime.MinValue; } return chapter; diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 80b00a8c5..cf47e8688 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -14,6 +14,7 @@ using API.DTOs.Metadata; using API.DTOs.ReadingLists; using API.DTOs.Search; using API.DTOs.SeriesDetail; +using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -135,8 +136,10 @@ public interface ISeriesRepository Task> GetLibraryIdsForSeriesAsync(); Task> GetSeriesMetadataForIds(IEnumerable seriesIds); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat, bool customOnly = true); - Task GetSeriesDtoByNamesAndMetadataIdsForUser(int userId, IEnumerable names, LibraryType libraryType, string aniListUrl, string malUrl); + Task GetAverageUserRating(int seriesId); + Task RemoveFromOnDeck(int seriesId, int userId); + Task ClearOnDeckRemoval(int seriesId, int userId); } public class SeriesRepository : ISeriesRepository @@ -757,16 +760,33 @@ public class SeriesRepository : ISeriesRepository /// public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter) { - var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); - var cutoffLastAddedPoint = DateTime.Now - TimeSpan.FromDays(7); + var settings = await _context.ServerSetting + .Select(x => x) + .AsNoTracking() + .ToListAsync(); + var serverSettings = _mapper.Map(settings); + + var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(serverSettings.OnDeckProgressDays); + var cutoffLastAddedPoint = DateTime.Now - TimeSpan.FromDays(serverSettings.OnDeckUpdateDays); var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard) .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + // Don't allow any series the user has explicitly removed + var onDeckRemovals = _context.AppUserOnDeckRemoval + .Where(d => d.AppUserId == userId) + .Select(d => d.SeriesId) + .AsEnumerable(); + + // var onDeckRemovals = _context.AppUser.Where(u => u.Id == userId) + // .SelectMany(u => u.OnDeckRemovals.Select(d => d.Id)) + // .AsEnumerable(); + var query = _context.Series .Where(s => usersSeriesIds.Contains(s.Id)) + .Where(s => !onDeckRemovals.Contains(s.Id)) .Select(s => new { Series = s, @@ -1658,6 +1678,42 @@ public class SeriesRepository : ISeriesRepository .FirstOrDefaultAsync(); // Some users may have improperly configured libraries } + /// + /// Returns the Average rating for all users within Kavita instance + /// + /// + public async Task GetAverageUserRating(int seriesId) + { + var avg = (await _context.AppUserRating + .Where(r => r.SeriesId == seriesId) + .AverageAsync(r => (int?) r.Rating)); + return avg.HasValue ? (int) avg.Value : 0; + } + + public async Task RemoveFromOnDeck(int seriesId, int userId) + { + var existingEntry = await _context.AppUserOnDeckRemoval + .Where(u => u.Id == userId && u.SeriesId == seriesId) + .AnyAsync(); + if (existingEntry) return; + _context.AppUserOnDeckRemoval.Add(new AppUserOnDeckRemoval() + { + SeriesId = seriesId, + AppUserId = userId + }); + await _context.SaveChangesAsync(); + } + + public async Task ClearOnDeckRemoval(int seriesId, int userId) + { + var existingEntry = await _context.AppUserOnDeckRemoval + .Where(u => u.Id == userId && u.SeriesId == seriesId) + .FirstOrDefaultAsync(); + if (existingEntry == null) return; + _context.AppUserOnDeckRemoval.Remove(existingEntry); + await _context.SaveChangesAsync(); + } + public async Task IsSeriesInWantToRead(int userId, int seriesId) { var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 0ab52a136..ccd909117 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -238,6 +238,7 @@ public class VolumeRepository : IVolumeRepository if (progresses.Count == 0) continue; c.PagesRead = progresses.Sum(p => p.PagesRead); c.LastReadingProgressUtc = progresses.Max(p => p.LastModifiedUtc); + c.LastReadingProgress = progresses.Max(p => p.LastModified); } v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 23bfb39d3..076f086cd 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,11 @@ 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.OnDeckProgressDays, Value = $"{30}"}, + new() {Key = ServerSettingKey.OnDeckUpdateDays, Value = $"{7}"}, + 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 +135,8 @@ public static class Seed directoryService.CacheDirectory + string.Empty; context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = DirectoryService.BackupDirectory + string.Empty; - + context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheSize).Value = + Configuration.CacheSize + string.Empty; await context.SaveChangesAsync(); } diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index bddf553ab..58f7d7033 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -37,6 +37,10 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// public ICollection Devices { get; set; } = null!; /// + /// A list of Series the user doesn't want on deck + /// + //public ICollection OnDeckRemovals { get; set; } = null!; + /// /// An API Key to interact with external services, like OPDS /// public string? ApiKey { get; set; } diff --git a/API/Entities/AppUserOnDeckRemoval.cs b/API/Entities/AppUserOnDeckRemoval.cs new file mode 100644 index 000000000..3b7b16f80 --- /dev/null +++ b/API/Entities/AppUserOnDeckRemoval.cs @@ -0,0 +1,11 @@ +namespace API.Entities; + +public class AppUserOnDeckRemoval +{ + public int Id { get; set; } + public int SeriesId { get; set; } + public Series Series { get; set; } + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } + +} diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index e7f4683a4..c8d9c12be 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -128,5 +128,20 @@ public enum ServerSettingKey /// [Description("LicenseKey")] LicenseKey = 23, + /// + /// The size in MB for Caching API data + /// + [Description("Cache")] + CacheSize = 24, + /// + /// How many Days since today in the past for reading progress, should content be considered for On Deck, before it gets removed automatically + /// + [Description("OnDeckProgressDays")] + OnDeckProgressDays = 25, + /// + /// How many Days since today in the past for chapter updates, should content be considered for On Deck, before it gets removed automatically + /// + [Description("OnDeckUpdateDays")] + OnDeckUpdateDays = 26, } 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..9163a027f 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -70,6 +70,15 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.HostName: destination.HostName = row.Value; break; + case ServerSettingKey.CacheSize: + destination.CacheSize = long.Parse(row.Value); + break; + case ServerSettingKey.OnDeckProgressDays: + destination.OnDeckProgressDays = int.Parse(row.Value); + break; + case ServerSettingKey.OnDeckUpdateDays: + destination.OnDeckUpdateDays = int.Parse(row.Value); + break; } } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 2dc7ef8f2..3459cbdf0 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -645,13 +645,13 @@ public class BookService : IBookService return Parser.CleanAuthor(person.Creator) + ","; } - private static (int year, int month, int day) GetPublicationDate(string publicationDate) + private static (int year, int month, int day) GetPublicationDate(string? publicationDate) { - var dateParsed = DateTime.TryParse(publicationDate, out var date); var year = 0; var month = 0; var day = 0; - switch (dateParsed) + if (string.IsNullOrEmpty(publicationDate)) return (year, month, day); + switch (DateTime.TryParse(publicationDate, out var date)) { case true: year = date.Year; diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 6dcb2f199..f4b18339e 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -83,7 +83,7 @@ public class DirectoryService : IDirectoryService private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; private static readonly Regex ExcludeDirectories = new Regex( - @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb", + @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb|\.caltrash", MatchOptions, Tasks.Scanner.Parser.Parser.RegexTimeout); private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)", diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index ddd67791e..0a2b25cea 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -130,11 +130,13 @@ public class LicenseService : ILicenseService { try { + var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); + if (string.IsNullOrEmpty(license.Value)) return; + _logger.LogInformation("Validating Kavita+ License"); var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); await provider.FlushAsync(); - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var isValid = await IsLicenseValid(license.Value); await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout); 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..9730cb9c6 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -22,9 +22,17 @@ using Microsoft.Extensions.Logging; namespace API.Services.Plus; +/// +/// Misleading name but is the source of data (like a review coming from AniList) +/// public enum ScrobbleProvider { - AniList = 1 + /// + /// For now, this means data comes from within this instance of Kavita + /// + Kavita = 0, + AniList = 1, + Mal = 2, } public interface IScrobblingService @@ -180,6 +188,7 @@ public class ScrobblingService : IScrobblingService if (series == null) throw new KavitaException("Series not found"); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); if (library is not {AllowScrobbling: true}) return; + if (library.Type == LibraryType.Comic) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, ScrobbleEventType.Review); @@ -224,6 +233,7 @@ public class ScrobblingService : IScrobblingService if (series == null) throw new KavitaException("Series not found"); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); if (library is not {AllowScrobbling: true}) return; + if (library.Type == LibraryType.Comic) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, ScrobbleEventType.ScoreUpdated); @@ -272,6 +282,7 @@ public class ScrobblingService : IScrobblingService } var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); if (library is not {AllowScrobbling: true}) return; + if (library.Type == LibraryType.Comic) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, ScrobbleEventType.ChapterRead); @@ -331,6 +342,7 @@ public class ScrobblingService : IScrobblingService if (series == null) throw new KavitaException("Series not found"); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); if (library is not {AllowScrobbling: true}) return; + if (library.Type == LibraryType.Comic) return; var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id, onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead); @@ -466,10 +478,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 +518,7 @@ public class ScrobblingService : IScrobblingService foreach (var series in seriesWithProgress) { + if (!libAllowsScrobbling[series.LibraryId]) continue; await ScrobbleReadingUpdate(uId, series.Id); } @@ -687,7 +701,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/Services/ReaderService.cs b/API/Services/ReaderService.cs index b320519fc..c1847bf8a 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -286,6 +286,8 @@ public class ReaderService : IReaderService BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, progressDto.SeriesId)); } + BackgroundJob.Enqueue(() => _unitOfWork.SeriesRepository.ClearOnDeckRemoval(progressDto.SeriesId, userId)); + return true; } } diff --git a/API/Services/ReviewService.cs b/API/Services/ReviewService.cs index 597e7bbe9..a0f4d18f5 100644 --- a/API/Services/ReviewService.cs +++ b/API/Services/ReviewService.cs @@ -35,6 +35,7 @@ internal class MediaReviewDto /// public string RawBody { get; set; } public string Username { get; set; } + public ScrobbleProvider Provider { get; set; } } public interface IReviewService @@ -74,6 +75,7 @@ public class ReviewService : IReviewService LibraryId = series.LibraryId, SeriesId = series.Id, IsExternal = true, + Provider = r.Provider, BodyJustText = GetCharacters(r.Body), ExternalUrl = r.SiteUrl }); diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 5e2726ea3..ea95de1fd 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -995,7 +995,9 @@ public static class Parser /// public static bool HasBlacklistedFolderInPath(string path) { - return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") || path.StartsWith("._") || Path.GetFileName(path).StartsWith("._") || path.Contains(".qpkg"); + return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") + || path.StartsWith("._") || Path.GetFileName(path).StartsWith("._") || path.Contains(".qpkg") + || path.Contains(".caltrash"); } diff --git a/API/Services/Tasks/Scanner/Parser/ParserInfo.cs b/API/Services/Tasks/Scanner/Parser/ParserInfo.cs index 4f860b75e..8cd81cf6d 100644 --- a/API/Services/Tasks/Scanner/Parser/ParserInfo.cs +++ b/API/Services/Tasks/Scanner/Parser/ParserInfo.cs @@ -2,6 +2,7 @@ using API.Entities.Enums; namespace API.Services.Tasks.Scanner.Parser; +#nullable enable /// /// This represents all parsed information from a single file @@ -12,7 +13,7 @@ public class ParserInfo /// Represents the parsed chapters from a file. By default, will be 0 which means nothing could be parsed. /// The chapters can only be a single float or a range of float ie) 1-2. Mainly floats should be multiples of 0.5 representing specials /// - public string Chapters { get; set; } = ""; + public string Chapters { get; set; } = string.Empty; /// /// Represents the parsed series from the file or folder /// @@ -31,17 +32,17 @@ public class ParserInfo /// Beastars Vol 3-4 will map to "3-4" /// The volumes can only be a single int or a range of ints ie) 1-2. Float based volumes are not supported. /// - public string Volumes { get; set; } = ""; + public string Volumes { get; set; } = string.Empty; /// /// Filename of the underlying file /// Beastars v01 (digital).cbz /// - public string Filename { get; init; } = ""; + public string Filename { get; init; } = string.Empty; /// /// Full filepath of the underlying file /// C:/Manga/Beastars v01 (digital).cbz /// - public string FullFilePath { get; set; } = ""; + public string FullFilePath { get; set; } = string.Empty; /// /// that represents the type of the file @@ -53,7 +54,7 @@ public class ParserInfo /// This can potentially story things like "Omnibus, Color, Full Contact Edition, Extra, Final, etc" /// /// Not Used in Database - public string Edition { get; set; } = ""; + public string Edition { get; set; } = string.Empty; /// /// If the file contains no volume/chapter information or contains Special Keywords @@ -72,7 +73,7 @@ public class ParserInfo /// public bool IsSpecialInfo() { - return (IsSpecial || (Volumes == "0" && Chapters == "0")); + return (IsSpecial || (Volumes == Parser.DefaultVolume && Chapters == Parser.DefaultChapter)); } /// @@ -89,8 +90,8 @@ public class ParserInfo public void Merge(ParserInfo? info2) { if (info2 == null) return; - Chapters = string.IsNullOrEmpty(Chapters) || Chapters == "0" ? info2.Chapters: Chapters; - Volumes = string.IsNullOrEmpty(Volumes) || Volumes == "0" ? info2.Volumes : Volumes; + Chapters = string.IsNullOrEmpty(Chapters) || Chapters == Parser.DefaultChapter ? info2.Chapters: Chapters; + Volumes = string.IsNullOrEmpty(Volumes) || Volumes == Parser.DefaultVolume ? info2.Volumes : Volumes; Edition = string.IsNullOrEmpty(Edition) ? info2.Edition : Edition; Title = string.IsNullOrEmpty(Title) ? info2.Title : Title; Series = string.IsNullOrEmpty(Series) ? info2.Series : Series; diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 0efb3002f..5569e8640 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -164,7 +164,7 @@ public class ScannerService : IScannerService var libraries = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()).ToList(); var libraryFolders = libraries.SelectMany(l => l.Folders); - var libraryFolder = libraryFolders.Select(Scanner.Parser.Parser.NormalizePath).SingleOrDefault(f => f.Contains(parentDirectory)); + var libraryFolder = libraryFolders.Select(Scanner.Parser.Parser.NormalizePath).FirstOrDefault(f => f.Contains(parentDirectory)); if (string.IsNullOrEmpty(libraryFolder)) return; var library = libraries.Find(l => l.Folders.Select(Parser.NormalizePath).Contains(libraryFolder)); diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index b1d54c03a..e4f997767 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -23,6 +23,7 @@ public interface IStatsService { Task Send(); Task GetServerInfo(); + Task GetServerInfoSlim(); Task SendCancellation(); } /// @@ -171,6 +172,17 @@ public class StatsService : IStatsService return serverInfo; } + public async Task GetServerInfoSlim() + { + var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + return new ServerInfoSlimDto() + { + InstallId = serverSettings.InstallId, + KavitaVersion = serverSettings.InstallVersion, + IsDocker = OsInfo.IsDocker + }; + } + public async Task SendCancellation() { _logger.LogInformation("Informing KavitaStats that this instance is no longer sending stats"); diff --git a/API/Startup.cs b/API/Startup.cs index 90a104158..64f2a3f73 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -116,7 +116,7 @@ public class Startup Location = ResponseCacheLocation.Client, NoStore = false }); - options.CacheProfiles.Add(ResponseCacheProfiles.Recommendation, + options.CacheProfiles.Add(ResponseCacheProfiles.KavitaPlus, new CacheProfile() { Duration = TimeSpan.FromDays(30).Seconds, 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/API/config/appsettings.json b/API/config/appsettings.json index e04e9eaa4..3eeee1c18 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -1,5 +1,5 @@ { - "TokenKey": "super secret unguessable key", + "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, "IpAddresses": "", "BaseUrl": "/", diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index f6b7d086e..56c3f0401 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 == 0 ? DefaultCacheMemory : 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) { @@ -358,12 +324,14 @@ public static class Configuration { public string TokenKey { get; set; } // ReSharper disable once MemberHidesStaticFromOuterClass - public int Port { get; set; } + public int Port { get; set; } = DefaultHttpPort; // ReSharper disable once MemberHidesStaticFromOuterClass public string IpAddresses { get; set; } = string.Empty; // ReSharper disable once MemberHidesStaticFromOuterClass public string BaseUrl { get; set; } // ReSharper disable once MemberHidesStaticFromOuterClass - public int Cache { get; set; } + public long Cache { get; set; } = DefaultCacheMemory; + // ReSharper disable once MemberHidesStaticFromOuterClass + public string XFrameOrigins { get; set; } = DefaultXFrameOptions; } } diff --git a/Kavita.Common/HashUtil.cs b/Kavita.Common/HashUtil.cs index d3757085d..4956682b3 100644 --- a/Kavita.Common/HashUtil.cs +++ b/Kavita.Common/HashUtil.cs @@ -45,31 +45,7 @@ public static class HashUtil public static string ServerToken() { - var seed = new DeviceIdBuilder() - .AddMacAddress() - .AddUserName() - .AddComponent("ProcessorCount", new DeviceIdComponent($"{Environment.ProcessorCount}")) - .AddComponent("OSPlatform", new DeviceIdComponent($"{Environment.OSVersion.Platform}")) - .OnWindows(windows => windows - .AddSystemUuid() - .AddMotherboardSerialNumber() - .AddSystemDriveSerialNumber()) - .OnLinux(linux => - { - var osInfo = RunAndCapture("uname", "-a"); - if (Regex.IsMatch(osInfo, @"\bUnraid\b")) - { - var cpuModel = RunAndCapture("lscpu", string.Empty); - var match = Regex.Match(cpuModel, @"Model name:\s+(.+)"); - linux.AddComponent("CPUModel", new DeviceIdComponent($"{match.Groups[1].Value.Trim()}")); - return; - } - linux.AddMotherboardSerialNumber(); - }) - .OnMac(mac => mac - .AddSystemDriveSerialNumber()) - .ToString(); - return CalculateCrc(seed); + return AnonymousToken(); } /// diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 218d5d837..b7a50ce53 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -4,7 +4,7 @@ net7.0 kavitareader.com Kavita - 0.7.4.0 + 0.7.5.0 en true diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index e0117a315..ca95de81b 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -26,7 +26,7 @@ export interface Chapter { * Actual name of the Chapter if populated in underlying metadata */ titleName: string; - /** + /** * Summary for the chapter */ summary?: string; @@ -43,4 +43,5 @@ export interface Chapter { volumeTitle?: string; webLinks: string; isbn: string; + lastReadingProgress: string; } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index e61f2b481..309bf43b8 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -88,6 +88,10 @@ export enum Action { * Import some data into Kavita */ Import = 18, + /** + * Removes the Series from On Deck inclusion + */ + RemoveFromOnDeck = 19, } export interface ActionItem { @@ -563,9 +567,7 @@ export class ActionFactoryService { // Checks the whole tree for the action and returns true if it exists public hasAction(actions: Array>, action: Action) { - var actionFound = false; - - if (actions.length === 0) return actionFound; + if (actions.length === 0) return false; for (let i = 0; i < actions.length; i++) { @@ -573,8 +575,7 @@ export class ActionFactoryService { if (this.hasAction(actions[i].children, action)) return true; } - - return actionFound; + return false; } } 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/_services/scrobbling.service.ts b/UI/Web/src/app/_services/scrobbling.service.ts index adb974467..e815410f7 100644 --- a/UI/Web/src/app/_services/scrobbling.service.ts +++ b/UI/Web/src/app/_services/scrobbling.service.ts @@ -24,8 +24,9 @@ import {UtilityService} from "../shared/_services/utility.service"; import {ReadingList} from "../_models/reading-list"; export enum ScrobbleProvider { + Kavita = 0, AniList= 1, - Mal = 2 + Mal = 2, } @Injectable({ diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 3b95e0fa8..edc83e520 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -32,12 +32,12 @@ export class SeriesService { paginatedSeriesForTagsResults: PaginatedResult = new PaginatedResult(); constructor(private httpClient: HttpClient, private imageService: ImageService, - private utilityService: UtilityService, private filterUtilitySerivce: FilterUtilitiesService) { } + private utilityService: UtilityService, private filterUtilityService: FilterUtilitiesService) { } getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - const data = this.filterUtilitySerivce.createSeriesFilter(filter); + const data = this.filterUtilityService.createSeriesFilter(filter); return this.httpClient.post>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe( map((response: any) => { @@ -49,7 +49,7 @@ export class SeriesService { getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - const data = this.filterUtilitySerivce.createSeriesFilter(filter); + const data = this.filterUtilityService.createSeriesFilter(filter); return this.httpClient.post>(this.baseUrl + 'series?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map((response: any) => { @@ -103,7 +103,7 @@ export class SeriesService { } getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { - const data = this.filterUtilitySerivce.createSeriesFilter(filter); + const data = this.filterUtilityService.createSeriesFilter(filter); let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); @@ -119,7 +119,7 @@ export class SeriesService { } getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter): Observable> { - const data = this.filterUtilitySerivce.createSeriesFilter(filter); + const data = this.filterUtilityService.createSeriesFilter(filter); let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); @@ -138,7 +138,7 @@ export class SeriesService { } getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { - const data = this.filterUtilitySerivce.createSeriesFilter(filter); + const data = this.filterUtilityService.createSeriesFilter(filter); let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); @@ -223,4 +223,11 @@ export class SeriesService { getRatings(seriesId: number) { return this.httpClient.get>(this.baseUrl + 'rating?seriesId=' + seriesId); } + getOverallRating(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'rating/overall?seriesId=' + seriesId); + } + + removeFromOnDeck(seriesId: number) { + return this.httpClient.post(this.baseUrl + 'series/remove-from-on-deck?seriesId=' + seriesId, {}); + } } diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index de168ee5b..543d36aad 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; -import { ServerInfo } from '../admin/_models/server-info'; +import {ServerInfoSlim} from '../admin/_models/server-info'; import { UpdateVersionEvent } from '../_models/events/update-version-event'; import { Job } from '../_models/job/job'; import { KavitaMediaError } from '../admin/_models/media-error'; @@ -17,7 +17,7 @@ export class ServerService { getServerInfo() { - return this.httpClient.get(this.baseUrl + 'server/server-info'); + return this.httpClient.get(this.baseUrl + 'server/server-info-slim'); } clearCache() { diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.html b/UI/Web/src/app/_single-module/review-card/review-card.component.html index 46360a1a6..5e912aba8 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.html +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.html @@ -19,9 +19,15 @@

- + diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.scss b/UI/Web/src/app/_single-module/review-card/review-card.component.scss index f1794eba6..62bf3e443 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.scss +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.scss @@ -8,7 +8,10 @@ z-index: 20; top: 38px; left: 38px; - color: var(--review-card-star-color); +} + +.fa-star { + color: var(--review-card-star-color); } .card-text { @@ -29,10 +32,6 @@ overflow: hidden; } -.card-footer { - width: 288px; -} - .card { cursor: pointer; } diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.ts b/UI/Web/src/app/_single-module/review-card/review-card.component.ts index 13811735f..154f0cd72 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.ts +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.ts @@ -1,5 +1,5 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; -import {CommonModule} from '@angular/common'; +import {CommonModule, NgOptimizedImage} from '@angular/common'; import {UserReview} from "./user-review"; import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; import {ReviewCardModalComponent} from "../review-card-modal/review-card-modal.component"; @@ -7,11 +7,13 @@ import {AccountService} from "../../_services/account.service"; import {ReviewSeriesModalComponent} from "../review-series-modal/review-series-modal.component"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {DefaultValuePipe} from "../../pipe/default-value.pipe"; +import {ImageComponent} from "../../shared/image/image.component"; +import {ProviderImagePipe} from "../../pipe/provider-image.pipe"; @Component({ selector: 'app-review-card', standalone: true, - imports: [CommonModule, ReadMoreComponent, DefaultValuePipe], + imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe], templateUrl: './review-card.component.html', styleUrls: ['./review-card.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/_single-module/review-card/user-review.ts b/UI/Web/src/app/_single-module/review-card/user-review.ts index 1e0371fc8..f735d9548 100644 --- a/UI/Web/src/app/_single-module/review-card/user-review.ts +++ b/UI/Web/src/app/_single-module/review-card/user-review.ts @@ -1,3 +1,5 @@ +import {ScrobbleProvider} from "../../_services/scrobbling.service"; + export interface UserReview { seriesId: number; libraryId: number; @@ -8,4 +10,5 @@ export interface UserReview { isExternal: boolean; bodyJustText?: string; externalUrl?: string; + provider: ScrobbleProvider; } diff --git a/UI/Web/src/app/admin/_models/server-info.ts b/UI/Web/src/app/admin/_models/server-info.ts index d13419f96..751c87534 100644 --- a/UI/Web/src/app/admin/_models/server-info.ts +++ b/UI/Web/src/app/admin/_models/server-info.ts @@ -1,9 +1,5 @@ -export interface ServerInfo { - os: string; - dotnetVersion: string; - runTimeVersion: string; +export interface ServerInfoSlim { kavitaVersion: string; - NumOfCores: number; installId: string; isDocker: boolean; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index a0b6667d7..b72396a8c 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -17,4 +17,7 @@ export interface ServerSettings { totalLogs: number; enableFolderWatching: boolean; hostName: string; + cacheSize: number; + onDeckProgressDays: number; + onDeckUpdateDays: 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..bde8a9633 100644 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.html +++ b/UI/Web/src/app/admin/dashboard/dashboard.component.html @@ -36,12 +36,9 @@ -

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

- - Nothing here yet. This will be built out in a future update. - 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..925a806a1 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,16 +97,66 @@
-
- -

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 75MB. + The amount of memory allowed for caching heavy APIs. Default is 50MB. + + +

+ You must have at least 50 MB. +

+

+ This field is required +

+
+
+
+   + The number of days since last progress before kicking something off On Deck. + The number of days since last progress before kicking something off On Deck. + + +

+ Must be at least 1 day +

+

+ This field is required +

+
+
+
+   + The number of days since last chapter was added to include something On Deck. + The number of days since last chapter was added to include something On Deck. + + +

+ Must be at least 1 day +

+

+ 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.

+
+ + +
-

OPDS support will allow all users to use OPDS to read and download content from the server.

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..72bddde99 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 @@ -25,10 +25,6 @@ export class ManageSettingsComponent implements OnInit { taskFrequencies: Array = []; logLevels: Array = []; - get TagBadgeCursor() { - return TagBadgeCursor; - } - constructor(private settingsService: SettingsService, private toastr: ToastrService, private serverService: ServerService) { } @@ -52,10 +48,13 @@ 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, [])); this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, [Validators.pattern(/^(http:|https:)+[^\s]+[\w]$/)])); + this.settingsForm.addControl('onDeckProgressDays', new FormControl(this.serverSettings.onDeckProgressDays, [Validators.required])); + this.settingsForm.addControl('onDeckUpdateDays', new FormControl(this.serverSettings.onDeckUpdateDays, [Validators.required])); this.serverService.getServerInfo().subscribe(info => { if (info.isDocker) { @@ -82,6 +81,9 @@ 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.get('onDeckProgressDays')?.setValue(this.serverSettings.onDeckProgressDays); + this.settingsForm.get('onDeckUpdateDays')?.setValue(this.serverSettings.onDeckUpdateDays); this.settingsForm.markAsPristine(); } @@ -127,5 +129,5 @@ export class ManageSettingsComponent implements OnInit { }); } - + } diff --git a/UI/Web/src/app/admin/manage-system/manage-system.component.ts b/UI/Web/src/app/admin/manage-system/manage-system.component.ts index 35c349fa4..dbdc87d8d 100644 --- a/UI/Web/src/app/admin/manage-system/manage-system.component.ts +++ b/UI/Web/src/app/admin/manage-system/manage-system.component.ts @@ -4,7 +4,7 @@ import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; import { ServerService } from 'src/app/_services/server.service'; import { SettingsService } from '../settings.service'; -import { ServerInfo } from '../_models/server-info'; +import {ServerInfoSlim} from '../_models/server-info'; import { ServerSettings } from '../_models/server-settings'; import { NgIf } from '@angular/common'; @@ -19,10 +19,10 @@ export class ManageSystemComponent implements OnInit { settingsForm: FormGroup = new FormGroup({}); serverSettings!: ServerSettings; - serverInfo!: ServerInfo; + serverInfo!: ServerInfoSlim; - constructor(private settingsService: SettingsService, private toastr: ToastrService, + constructor(private settingsService: SettingsService, private toastr: ToastrService, private serverService: ServerService) { } ngOnInit(): void { 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/entity-info-cards/entity-info-cards.component.html b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html index 03af68c63..e325170a9 100644 --- a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html +++ b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html @@ -94,5 +94,14 @@
+ + +
+
+ + {{chapter.lastReadingProgress | date: 'shortDate'}} + +
+
diff --git a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts index 15112ae87..8259129df 100644 --- a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts +++ b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts @@ -1,5 +1,11 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, inject } from '@angular/core'; -import { Subject } from 'rxjs'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnInit, + inject, +} from '@angular/core'; import { UtilityService } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { ChapterMetadata } from 'src/app/_models/metadata/chapter-metadata'; @@ -17,16 +23,17 @@ import {DefaultDatePipe} from "../../pipe/default-date.pipe"; import {BytesPipe} from "../../pipe/bytes.pipe"; import {CompactNumberPipe} from "../../pipe/compact-number.pipe"; import {AgeRatingPipe} from "../../pipe/age-rating.pipe"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; @Component({ selector: 'app-entity-info-cards', standalone: true, - imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, AgeRatingPipe], + imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, AgeRatingPipe, NgbTooltip], templateUrl: './entity-info-cards.component.html', styleUrls: ['./entity-info-cards.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class EntityInfoCardsComponent implements OnInit, OnDestroy { +export class EntityInfoCardsComponent implements OnInit { @Input({required: true}) entity!: Volume | Chapter; /** @@ -49,7 +56,6 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy { readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1}; size: number = 0; - private readonly onDestroy: Subject = new Subject(); imageService = inject(ImageService); get LibraryType() { @@ -69,6 +75,8 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy { return this.chapter.webLinks.split(','); } + + constructor(private utilityService: UtilityService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {} ngOnInit(): void { @@ -119,8 +127,8 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy { this.cdRef.markForCheck(); } - ngOnDestroy(): void { - this.onDestroy.next(); - this.onDestroy.complete(); + getTimezone(timezone: string): string { + const localDate = new Date(timezone); + return localDate.toLocaleString('en-US', { timeZoneName: 'short' }).split(' ')[3]; } } 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..f7dac176d 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 @@ -8,19 +8,20 @@ import { OnInit, Output } from '@angular/core'; -import { Router } from '@angular/router'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { ToastrService } from 'ngx-toastr'; -import { Series } from 'src/app/_models/series'; -import { ImageService } from 'src/app/_services/image.service'; -import { ActionFactoryService, Action, ActionItem } from 'src/app/_services/action-factory.service'; -import { SeriesService } from 'src/app/_services/series.service'; -import { ActionService } from 'src/app/_services/action.service'; -import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component'; -import { RelationKind } from 'src/app/_models/series-detail/relation-kind'; +import {Router} from '@angular/router'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {ToastrService} from 'ngx-toastr'; +import {Series} from 'src/app/_models/series'; +import {ImageService} from 'src/app/_services/image.service'; +import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; +import {SeriesService} from 'src/app/_services/series.service'; +import {ActionService} from 'src/app/_services/action.service'; +import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component'; +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', @@ -46,6 +47,10 @@ export class SeriesCardComponent implements OnInit, OnChanges { * If the Series has a relationship to display */ @Input() relation: RelationKind | undefined = undefined; + /** + * When a series card is shown on deck, a special actionable is added to the list + */ + @Input() isOnDeck: boolean = false; @Output() clicked = new EventEmitter(); /** @@ -78,6 +83,19 @@ export class SeriesCardComponent implements OnInit, OnChanges { ngOnChanges(changes: any) { if (this.data) { this.actions = this.actionFactoryService.getSeriesActions((action: ActionItem, series: Series) => this.handleSeriesActionCallback(action, series)); + if (this.isOnDeck) { + const othersIndex = this.actions.findIndex(obj => obj.title === 'Others'); + if (this.actions[othersIndex].children.findIndex(o => o.action === Action.RemoveFromOnDeck) < 0) { + this.actions[othersIndex].children.push({ + action: Action.RemoveFromOnDeck, + title: 'Remove From On Deck', + callback: (action: ActionItem, series: Series) => this.handleSeriesActionCallback(action, series), + class: 'danger', + requiresAdmin: false, + children: [], + }); + } + } this.cdRef.markForCheck(); } } @@ -120,6 +138,13 @@ 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; + case Action.RemoveFromOnDeck: + this.seriesService.removeFromOnDeck(series.id).subscribe(() => this.reload.emit(series.id)); + break; default: break; } diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.html b/UI/Web/src/app/dashboard/_components/dashboard.component.html index def199198..cfb2c2da5 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.html +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.html @@ -15,7 +15,8 @@ - + diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.ts b/UI/Web/src/app/dashboard/_components/dashboard.component.ts index 4bd6b58c0..f0e757c54 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.ts @@ -120,16 +120,7 @@ export class DashboardComponent implements OnInit { this.loadRecentlyAddedSeries(); } - reloadInProgress(series: Series | boolean) { - if (series === true || series === false) { - if (!series) {return;} - } - // If the update to Series doesn't affect the requirement to be in this stream, then ignore update request - const seriesObj = (series as Series); - if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) { - return; - } - + reloadInProgress(series: Series | number) { this.loadOnDeck(); } 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/pipe/provider-image.pipe.ts b/UI/Web/src/app/pipe/provider-image.pipe.ts index e87d93fe2..edb7b63b1 100644 --- a/UI/Web/src/app/pipe/provider-image.pipe.ts +++ b/UI/Web/src/app/pipe/provider-image.pipe.ts @@ -13,6 +13,8 @@ export class ProviderImagePipe implements PipeTransform { return 'assets/images/ExternalServices/AniList.png'; case ScrobbleProvider.Mal: return 'assets/images/ExternalServices/MAL.png'; + case ScrobbleProvider.Kavita: + return 'assets/images/logo-32.png'; } return ''; diff --git a/UI/Web/src/app/pipe/provider-name.pipe.ts b/UI/Web/src/app/pipe/provider-name.pipe.ts new file mode 100644 index 000000000..92ba49fcb --- /dev/null +++ b/UI/Web/src/app/pipe/provider-name.pipe.ts @@ -0,0 +1,23 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {ScrobbleProvider} from "../_services/scrobbling.service"; + +@Pipe({ + name: 'providerName', + standalone: true +}) +export class ProviderNamePipe implements PipeTransform { + + transform(value: ScrobbleProvider): string { + switch (value) { + case ScrobbleProvider.AniList: + return 'AniList'; + case ScrobbleProvider.Mal: + return 'MAL'; + case ScrobbleProvider.Kavita: + return 'Kavita'; + } + + return ''; + } + +} diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html index 1980ef54a..290fe4f44 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html @@ -1,13 +1,16 @@
-
+
- {{userRating * 20}}% + {{userRating * 20}} + + {{overallRating}}% + %
-
+
{{rating.averageScore}}% @@ -23,5 +26,9 @@ - + {{userRating * 20}}% + + + + {{rating.favoriteCount}} diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.scss b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.scss index 816baab5e..bf59d1ef5 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.scss +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.scss @@ -2,3 +2,21 @@ padding-left: 0px; padding-right: 0px; } + +.sm-popover { + width: 150px; + + > .popover-body { + padding-top: 0px; + } +} + +.md-popover { + width: 214px; + + > .popover-body { + padding-top: 0px; + } +} + + diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts index 8471d6090..e4b4d10a4 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts @@ -1,4 +1,12 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + Input, + OnInit, + ViewEncapsulation +} from '@angular/core'; import {CommonModule, NgOptimizedImage} from '@angular/common'; import {SeriesService} from "../../../_services/series.service"; import {Rating} from "../../../_models/rating"; @@ -7,14 +15,16 @@ import {NgbPopover, NgbRating} from "@ng-bootstrap/ng-bootstrap"; import {LoadingComponent} from "../../../shared/loading/loading.component"; import {AccountService} from "../../../_services/account.service"; import {LibraryType} from "../../../_models/library"; +import {ProviderNamePipe} from "../../../pipe/provider-name.pipe"; @Component({ selector: 'app-external-rating', standalone: true, - imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent], + imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe], templateUrl: './external-rating.component.html', styleUrls: ['./external-rating.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None }) export class ExternalRatingComponent implements OnInit { @Input({required: true}) seriesId!: number; @@ -26,10 +36,13 @@ export class ExternalRatingComponent implements OnInit { ratings: Array = []; isLoading: boolean = false; + overallRating: number = -1; ngOnInit() { + this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore); + this.accountService.hasValidLicense$.subscribe((res) => { if (!res) return; this.isLoading = true; 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/environments/environment.ts b/UI/Web/src/environments/environment.ts index 05f42e187..11db3c23e 100644 --- a/UI/Web/src/environments/environment.ts +++ b/UI/Web/src/environments/environment.ts @@ -6,7 +6,7 @@ export const environment = { production: false, apiUrl: 'http://localhost:5000/api/', hubUrl: 'http://localhost:5000/hubs/', - buyLink: 'https://buy.stripe.com/test_8wM4ie2dg5j77o4cMO?prefilled_promo_code=FREETRIAL', + buyLink: 'https://buy.stripe.com/test_9AQ5mi058h1PcIo3cf?prefilled_promo_code=FREETRIAL', manageLink: 'https://billing.stripe.com/p/login/test_14kfZocuh6Tz5ag7ss' }; 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..03774ecff 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.5" }, "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": [ @@ -2796,7 +2827,7 @@ "tags": [ "License" ], - "summary": "Updates server license. Returns true if updated and valid", + "summary": "Updates server license", "description": "Caches the result", "requestBody": { "content": { @@ -3916,6 +3947,45 @@ } } }, + "/api/Rating/overall": { + "get": { + "tags": [ + "Rating" + ], + "parameters": [ + { + "name": "seriesId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RatingDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RatingDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RatingDto" + } + } + } + } + } + } + }, "/api/Reader/pdf": { "get": { "tags": [ @@ -6494,9 +6564,12 @@ "in": "query", "schema": { "enum": [ - 1 + 0, + 1, + 2 ], "type": "integer", + "description": "Misleading name but is the source of data (like a review coming from AniList)", "format": "int32" } } @@ -7676,6 +7749,30 @@ } } }, + "/api/Series/remove-from-on-deck": { + "post": { + "tags": [ + "Series" + ], + "summary": "Removes a series from displaying on deck until the next read event on that series", + "parameters": [ + { + "name": "seriesId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/Series/refresh-metadata": { "post": { "tags": [ @@ -8287,6 +8384,37 @@ } } }, + "/api/Server/server-info-slim": { + "get": { + "tags": [ + "Server" + ], + "summary": "Returns non-sensitive information about the current system", + "description": "This is just for the UI and is extremly lightweight", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ServerInfoDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerInfoDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ServerInfoDto" + } + } + } + } + } + } + }, "/api/Server/convert-media": { "post": { "tags": [ @@ -8509,7 +8637,7 @@ "tags": [ "Server" ], - "summary": "Bust Review and Recommendation Cache", + "summary": "Bust Kavita+ Cache", "responses": { "200": { "description": "Success" @@ -11643,6 +11771,11 @@ "description": "The last time a chapter was read by current authenticated user", "format": "date-time" }, + "lastReadingProgress": { + "type": "string", + "description": "The last time a chapter was read by current authenticated user", + "format": "date-time" + }, "coverImageLocked": { "type": "boolean", "description": "If the Cover Image is locked for this entity" @@ -13700,9 +13833,12 @@ }, "provider": { "enum": [ - 1 + 0, + 1, + 2 ], "type": "integer", + "description": "Misleading name but is the source of data (like a review coming from AniList)", "format": "int32" } }, @@ -14650,6 +14786,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 +15932,21 @@ "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" + }, + "onDeckProgressDays": { + "type": "integer", + "description": "How many Days since today in the past for reading progress, should content be considered for On Deck, before it gets removed automatically", + "format": "int32" + }, + "onDeckUpdateDays": { + "type": "integer", + "description": "How many Days since today in the past for chapter updates, should content be considered for On Deck, before it gets removed automatically", + "format": "int32" } }, "additionalProperties": false @@ -17130,6 +17295,16 @@ "type": "string", "description": "The main body with just text, for review preview", "nullable": true + }, + "provider": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "description": "If this review is External, which Provider did it come from", + "format": "int32" } }, "additionalProperties": false,