Kavita+ Enhancements (#2616)

This commit is contained in:
Joe Milazzo 2024-01-17 17:45:39 -06:00 committed by GitHub
parent 625c56b265
commit dd44f55747
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1056 additions and 468 deletions

View file

@ -233,7 +233,7 @@ public class DownloadController : BaseApiController
MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), $"Downloading {filename}", 1F));
return PhysicalFile(filePath, DefaultContentType, System.Web.HttpUtility.UrlEncode(filename), true);
return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(filename), true);
}
}

View file

@ -2,7 +2,6 @@
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Account;
using API.DTOs.License;
using API.Entities.Enums;
using API.Extensions;
@ -20,7 +19,8 @@ public class LicenseController(
IUnitOfWork unitOfWork,
ILogger<LicenseController> logger,
ILicenseService licenseService,
ILocalizationService localizationService)
ILocalizationService localizationService,
ITaskScheduler taskScheduler)
: BaseApiController
{
/// <summary>
@ -31,7 +31,12 @@ public class LicenseController(
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
public async Task<ActionResult<bool>> HasValidLicense(bool forceCheck = false)
{
return Ok(await licenseService.HasActiveLicense(forceCheck));
var ret = await licenseService.HasActiveLicense(forceCheck);
if (ret)
{
await taskScheduler.ScheduleKavitaPlusTasks();
}
return Ok(ret);
}
/// <summary>
@ -57,6 +62,7 @@ public class LicenseController(
setting.Value = null;
unitOfWork.SettingsRepository.Update(setting);
await unitOfWork.CommitAsync();
await taskScheduler.ScheduleKavitaPlusTasks();
return Ok();
}
@ -82,6 +88,7 @@ public class LicenseController(
try
{
await licenseService.AddLicense(dto.License.Trim(), dto.Email.Trim(), dto.DiscordId);
await taskScheduler.ScheduleKavitaPlusTasks();
}
catch (Exception ex)
{

View file

@ -8,9 +8,11 @@ using API.Data;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Metadata;
using API.DTOs.SeriesDetail;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using Kavita.Common.Extensions;
using Microsoft.AspNetCore.Mvc;
@ -18,17 +20,10 @@ namespace API.Controllers;
#nullable enable
public class MetadataController : BaseApiController
public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILicenseService licenseService,
IRatingService ratingService, IReviewService reviewService, IRecommendationService recommendationService, IExternalMetadataService metadataService)
: BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
public MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
}
/// <summary>
/// Fetches genres from the instance
/// </summary>
@ -41,10 +36,10 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
}
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId()));
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId()));
}
/// <summary>
@ -57,8 +52,8 @@ public class MetadataController : BaseApiController
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
{
return role.HasValue ?
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
}
/// <summary>
@ -73,9 +68,9 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
}
return Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
return Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
}
/// <summary>
@ -90,9 +85,9 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
}
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId()));
return Ok(await unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId()));
}
/// <summary>
@ -108,7 +103,7 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
return Ok(await unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
}
return Ok(Enum.GetValues<AgeRating>().Select(t => new AgeRatingDto()
@ -131,7 +126,7 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids is {Count: > 0})
{
return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
return Ok(unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
}
return Ok(Enum.GetValues<PublicationStatus>().Select(t => new PublicationStatusDto()
@ -152,10 +147,13 @@ public class MetadataController : BaseApiController
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
{
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
return Ok(await unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
}
/// <summary>
/// Returns all languages Kavita can accept
/// </summary>
/// <returns></returns>
[HttpGet("all-languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
public IEnumerable<LanguageDto> GetAllValidLanguages()
@ -177,9 +175,38 @@ public class MetadataController : BaseApiController
[HttpGet("chapter-summary")]
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
{
if (chapterId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
if (chapterId <= 0) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
return Ok(chapter.Summary);
}
/// <summary>
/// Fetches the details needed from Kavita+ for Series Detail page
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("series-detail-plus")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
public async Task<ActionResult<SeriesDetailPlusDto>> GetKavitaPlusSeriesDetailData(int seriesId)
{
var seriesDetail = new SeriesDetailPlusDto();
if (!await licenseService.HasActiveLicense())
{
seriesDetail.Recommendations = null;
seriesDetail.Ratings = Enumerable.Empty<RatingDto>();
return Ok(seriesDetail);
}
seriesDetail = await metadataService.GetSeriesDetail(User.GetUserId(), seriesId);
// Temp solution, needs to be updated with new API
// seriesDetail.Ratings = await ratingService.GetRatings(seriesId);
// seriesDetail.Reviews = await reviewService.GetReviewsForSeries(User.GetUserId(), seriesId);
// seriesDetail.Recommendations =
// await recommendationService.GetRecommendationsForSeries(User.GetUserId(), seriesId);
return Ok(seriesDetail);
}
}

View file

@ -44,26 +44,14 @@ public class RatingController : BaseApiController
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
public async Task<ActionResult<IEnumerable<RatingDto>>> GetRating(int seriesId)
{
if (!await _licenseService.HasActiveLicense())
{
return Ok(Enumerable.Empty<RatingDto>());
}
var cacheKey = CacheKey + seriesId;
var results = await _cacheProvider.GetAsync<IEnumerable<RatingDto>>(cacheKey);
if (results.HasValue)
{
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);
return Ok(await _ratingService.GetRatings(seriesId));
}
[HttpGet("overall")]

View file

@ -51,78 +51,10 @@ public class ReviewController : BaseApiController
/// </summary>
/// <param name="seriesId"></param>
[HttpGet]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
public async Task<ActionResult<IEnumerable<UserReviewDto>>> GetReviews(int seriesId)
{
var userId = User.GetUserId();
var username = User.GetUsername();
var userRatings = (await _unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, userId))
.Where(r => !string.IsNullOrEmpty(r.Body))
.OrderByDescending(review => review.Username.Equals(username) ? 1 : 0)
.ToList();
if (!await _licenseService.HasActiveLicense())
{
return Ok(userRatings);
}
var cacheKey = CacheKey + seriesId;
IList<UserReviewDto> externalReviews;
var result = await _cacheProvider.GetAsync<IEnumerable<UserReviewDto>>(cacheKey);
if (result.HasValue)
{
externalReviews = result.Value.ToList();
}
else
{
var reviews = (await _reviewService.GetReviewsForSeries(userId, seriesId)).ToList();
externalReviews = SelectSpectrumOfReviews(reviews);
await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10));
_logger.LogDebug("Caching external reviews for {Key}", cacheKey);
}
// Fetch external reviews and splice them in
userRatings.AddRange(externalReviews);
return Ok(userRatings);
}
private static IList<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
{
IList<UserReviewDto> externalReviews;
var totalReviews = reviews.Count;
if (totalReviews > 10)
{
var stepSize = Math.Max((totalReviews - 4) / 8, 1);
var selectedReviews = new List<UserReviewDto>()
{
reviews[0],
reviews[1],
};
for (var i = 2; i < totalReviews - 2; i += stepSize)
{
selectedReviews.Add(reviews[i]);
if (selectedReviews.Count >= 8)
break;
}
selectedReviews.Add(reviews[totalReviews - 2]);
selectedReviews.Add(reviews[totalReviews - 1]);
externalReviews = selectedReviews;
}
else
{
externalReviews = reviews;
}
return externalReviews;
return Ok(await _reviewService.GetReviewsForSeries(User.GetUserId(), seriesId));
}
/// <summary>

View file

@ -0,0 +1,15 @@
using System.Collections.Generic;
using API.DTOs.Recommendation;
namespace API.DTOs.SeriesDetail;
/// <summary>
/// All the data from Kavita+ for Series Detail
/// </summary>
/// <remarks>This is what the UI sees, not what the API sends back</remarks>
public class SeriesDetailPlusDto
{
public RecommendationDto Recommendations { get; set; }
public IEnumerable<UserReviewDto> Reviews { get; set; }
public IEnumerable<RatingDto> Ratings { get; set; }
}

View file

@ -1,11 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Recommendation;
using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Builders;
using Flurl.Http;
using Kavita.Common;
@ -29,9 +34,17 @@ internal class ExternalMetadataIdsDto
public MediaFormat? PlusMediaFormat { get; set; } = MediaFormat.Unknown;
}
internal class SeriesDetailPlusAPIDto
{
public IEnumerable<MediaRecommendationDto> Recommendations { get; set; }
public IEnumerable<UserReviewDto> Reviews { get; set; }
public IEnumerable<RatingDto> Ratings { get; set; }
}
public interface IExternalMetadataService
{
Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId);
Task<SeriesDetailPlusDto?> GetSeriesDetail(int userId, int seriesId);
}
public class ExternalMetadataService : IExternalMetadataService
@ -48,6 +61,14 @@ public class ExternalMetadataService : IExternalMetadataService
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
}
/// <summary>
/// Retrieves Metadata about a Recommended External Series
/// </summary>
/// <param name="aniListId"></param>
/// <param name="malId"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
/// <exception cref="KavitaException"></exception>
public async Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId)
{
if (!aniListId.HasValue && !malId.HasValue)
@ -60,6 +81,92 @@ public class ExternalMetadataService : IExternalMetadataService
}
public async Task<SeriesDetailPlusDto?> GetSeriesDetail(int userId, int seriesId)
{
var series =
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters);
if (series == null || series.Library.Type == LibraryType.Comic) return new SeriesDetailPlusDto();
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
try
{
var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/series-detail")
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-license-key", license)
.WithHeader("x-installId", HashUtil.ServerToken())
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
.PostJsonAsync(new PlusSeriesDtoBuilder(series).Build())
.ReceiveJson<SeriesDetailPlusAPIDto>();
var recs = await ProcessRecommendations(series, user!, result.Recommendations);
return new SeriesDetailPlusDto()
{
Recommendations = recs,
Ratings = result.Ratings,
Reviews = result.Reviews
};
}
catch (Exception e)
{
_logger.LogError(e, "An error happened during the request to Kavita+ API");
return null;
}
}
private async Task<RecommendationDto> ProcessRecommendations(Series series, AppUser user, IEnumerable<MediaRecommendationDto> recs)
{
var recDto = new RecommendationDto()
{
ExternalSeries = new List<ExternalSeriesDto>(),
OwnedSeries = new List<SeriesDto>()
};
var canSeeExternalSeries = user is {AgeRestriction: AgeRating.NotApplicable} &&
await _unitOfWork.UserRepository.IsUserAdminAsync(user);
foreach (var rec in recs)
{
// Find the series based on name and type and that the user has access too
var seriesForRec = await _unitOfWork.SeriesRepository.GetSeriesDtoByNamesAndMetadataIdsForUser(user.Id, rec.RecommendationNames,
series.Library.Type, ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, rec.AniListId),
ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, rec.MalId));
if (seriesForRec != null)
{
recDto.OwnedSeries.Add(seriesForRec);
continue;
}
if (!canSeeExternalSeries) continue;
// We can show this based on user permissions
if (string.IsNullOrEmpty(rec.Name) || string.IsNullOrEmpty(rec.SiteUrl) || string.IsNullOrEmpty(rec.CoverUrl)) continue;
recDto.ExternalSeries.Add(new ExternalSeriesDto()
{
Name = string.IsNullOrEmpty(rec.Name) ? rec.RecommendationNames.First() : rec.Name,
Url = rec.SiteUrl,
CoverUrl = rec.CoverUrl,
Summary = rec.Summary,
AniListId = rec.AniListId,
MalId = rec.MalId
});
}
await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, recDto.OwnedSeries);
recDto.OwnedSeries = recDto.OwnedSeries.DistinctBy(s => s.Id).OrderBy(r => r.Name).ToList();
recDto.ExternalSeries = recDto.ExternalSeries.DistinctBy(s => s.Name.ToNormalized()).OrderBy(r => r.Name).ToList();
return recDto;
}
private async Task<ExternalSeriesDetailDto?> GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId)
{
var payload = new ExternalMetadataIdsDto()

View file

@ -7,7 +7,6 @@ using API.DTOs.License;
using API.Entities.Enums;
using EasyCaching.Core;
using Flurl.Http;
using Hangfire;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.Extensions.Logging;
@ -24,31 +23,25 @@ internal class RegisterLicenseResponseDto
public interface ILicenseService
{
Task ValidateLicenseStatus();
//Task ValidateLicenseStatus();
Task RemoveLicense();
Task AddLicense(string license, string email, string? discordId);
Task<bool> HasActiveLicense(bool forceCheck = false);
Task<bool> HasActiveSubscription(string? license);
Task<bool> ResetLicense(string license, string email);
}
public class LicenseService : ILicenseService
public class LicenseService(
IEasyCachingProviderFactory cachingProviderFactory,
IUnitOfWork unitOfWork,
ILogger<LicenseService> logger)
: ILicenseService
{
private readonly IEasyCachingProviderFactory _cachingProviderFactory;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<LicenseService> _logger;
private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8);
public const string Cron = "0 */4 * * *";
private const string CacheKey = "license";
public LicenseService(IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork, ILogger<LicenseService> logger)
{
_cachingProviderFactory = cachingProviderFactory;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Performs license lookup to API layer
/// </summary>
@ -77,7 +70,7 @@ public class LicenseService : ILicenseService
}
catch (Exception e)
{
_logger.LogError(e, "An error happened during the request to Kavita+ API");
logger.LogError(e, "An error happened during the request to Kavita+ API");
throw;
}
}
@ -115,12 +108,12 @@ public class LicenseService : ILicenseService
return response.EncryptedLicense;
}
_logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response.ErrorMessage);
logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response.ErrorMessage);
throw new KavitaException(response.ErrorMessage);
}
catch (FlurlHttpException e)
{
_logger.LogError(e, "An error happened during the request to Kavita+ API");
logger.LogError(e, "An error happened during the request to Kavita+ API");
return string.Empty;
}
}
@ -129,57 +122,41 @@ public class LicenseService : ILicenseService
/// Checks licenses and updates cache
/// </summary>
/// <remarks>Expected to be called at startup and on reoccurring basis</remarks>
public async Task ValidateLicenseStatus()
{
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
try
{
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
if (string.IsNullOrEmpty(license.Value)) {
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
return;
}
_logger.LogInformation("Validating Kavita+ License");
await provider.FlushAsync();
var isValid = await IsLicenseValid(license.Value);
await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
_logger.LogInformation("Validating Kavita+ License - Complete");
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins");
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30));
}
}
public async Task RemoveLicense()
{
var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
serverSetting.Value = string.Empty;
_unitOfWork.SettingsRepository.Update(serverSetting);
await _unitOfWork.CommitAsync();
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
await provider.RemoveAsync(CacheKey);
}
public async Task AddLicense(string license, string email, string? discordId)
{
var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
var lic = await RegisterLicense(license, email, discordId);
if (string.IsNullOrWhiteSpace(lic))
throw new KavitaException("unable-to-register-k+");
serverSetting.Value = lic;
_unitOfWork.SettingsRepository.Update(serverSetting);
await _unitOfWork.CommitAsync();
}
// public async Task ValidateLicenseStatus()
// {
// var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
// try
// {
// var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
// if (string.IsNullOrEmpty(license.Value)) {
// await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
// return;
// }
//
// _logger.LogInformation("Validating Kavita+ License");
//
// await provider.FlushAsync();
// var isValid = await IsLicenseValid(license.Value);
// await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
//
// _logger.LogInformation("Validating Kavita+ License - Complete");
// }
// catch (Exception ex)
// {
// _logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins");
// await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
// BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30));
// }
// }
/// <summary>
/// Checks licenses and updates cache
/// </summary>
/// <param name="forceCheck">Skip what's in cache</param>
/// <returns></returns>
public async Task<bool> HasActiveLicense(bool forceCheck = false)
{
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
if (!forceCheck)
{
var cacheValue = await provider.GetAsync<bool>(CacheKey);
@ -188,7 +165,7 @@ public class LicenseService : ILicenseService
try
{
var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
var result = await IsLicenseValid(serverSetting.Value);
await provider.FlushAsync();
await provider.SetAsync(CacheKey, result, _licenseCacheTimeout);
@ -196,17 +173,77 @@ public class LicenseService : ILicenseService
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue connecting to Kavita+");
logger.LogError(ex, "There was an issue connecting to Kavita+");
}
return false;
}
public async Task<bool> HasActiveSubscription(string? license)
{
if (string.IsNullOrWhiteSpace(license)) return false;
try
{
var response = await (Configuration.KavitaPlusApiUrl + "/api/license/check-sub")
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-license-key", license)
.WithHeader("x-installId", HashUtil.ServerToken())
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
.PostJsonAsync(new LicenseValidDto()
{
License = license,
InstallId = HashUtil.ServerToken()
})
.ReceiveString();
var result = bool.Parse(response);
if (!result)
{
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
await provider.FlushAsync();
await provider.SetAsync(CacheKey, result, _licenseCacheTimeout);
}
return result;
}
catch (Exception e)
{
logger.LogError(e, "An error happened during the request to Kavita+ API");
throw;
}
}
public async Task RemoveLicense()
{
var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
serverSetting.Value = string.Empty;
unitOfWork.SettingsRepository.Update(serverSetting);
await unitOfWork.CommitAsync();
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
await provider.RemoveAsync(CacheKey);
}
public async Task AddLicense(string license, string email, string? discordId)
{
var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
var lic = await RegisterLicense(license, email, discordId);
if (string.IsNullOrWhiteSpace(lic))
throw new KavitaException("unable-to-register-k+");
serverSetting.Value = lic;
unitOfWork.SettingsRepository.Update(serverSetting);
await unitOfWork.CommitAsync();
}
public async Task<bool> ResetLicense(string license, string email)
{
try
{
var encryptedLicense = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
var response = await (Configuration.KavitaPlusApiUrl + "/api/license/reset")
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
@ -225,17 +262,17 @@ public class LicenseService : ILicenseService
if (string.IsNullOrEmpty(response))
{
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
await provider.RemoveAsync(CacheKey);
return true;
}
_logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response);
logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response);
throw new KavitaException(response);
}
catch (FlurlHttpException e)
{
_logger.LogError(e, "An error happened during the request to Kavita+ API");
logger.LogError(e, "An error happened during the request to Kavita+ API");
}
return false;

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
@ -10,6 +11,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Helpers.Builders;
using EasyCaching.Core;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
@ -28,25 +30,51 @@ public class RatingService : IRatingService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<RatingService> _logger;
private readonly IEasyCachingProvider _cacheProvider;
public RatingService(IUnitOfWork unitOfWork, ILogger<RatingService> logger)
public const string CacheKey = "rating_";
public RatingService(IUnitOfWork unitOfWork, ILogger<RatingService> logger, IEasyCachingProviderFactory cachingProviderFactory)
{
_unitOfWork = unitOfWork;
_logger = logger;
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
}
/// <summary>
/// Fetches Ratings for a given Series. Will check cache first
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
public async Task<IEnumerable<RatingDto>> GetRatings(int seriesId)
{
var cacheKey = CacheKey + seriesId;
var results = await _cacheProvider.GetAsync<IEnumerable<RatingDto>>(cacheKey);
if (results.HasValue)
{
return results.Value;
}
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<RatingDto>.Empty;
return await GetRatings(license.Value, series);
if (series == null || series.Library.Type == LibraryType.Comic)
{
await _cacheProvider.SetAsync(cacheKey, ImmutableList<RatingDto>.Empty, TimeSpan.FromHours(24));
return ImmutableList<RatingDto>.Empty;
}
var ratings = (await GetRatings(license.Value, series)).ToList();
await _cacheProvider.SetAsync(cacheKey, ratings, TimeSpan.FromHours(24));
_logger.LogDebug("Caching external rating for {Key}", cacheKey);
return ratings;
}
private async Task<IEnumerable<RatingDto>> GetRatings(string license, Series series)

View file

@ -40,7 +40,7 @@ public record PlusSeriesDto
public int? Year { get; set; }
}
internal record MediaRecommendationDto
public record MediaRecommendationDto
{
public int Rating { get; set; }
public IEnumerable<string> RecommendationNames { get; set; } = null!;
@ -126,7 +126,7 @@ public class RecommendationService : IRecommendationService
}
private async Task<IEnumerable<MediaRecommendationDto>> GetRecommendations(string license, Series series)
protected async Task<IEnumerable<MediaRecommendationDto>> GetRecommendations(string license, Series series)
{
try
{

View file

@ -7,7 +7,6 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Filtering;
using API.DTOs.Recommendation;
using API.DTOs.Scrobbling;
using API.Entities;
using API.Entities.Enums;
@ -78,7 +77,7 @@ public class ScrobblingService : IScrobblingService
{MangaDexWeblinkWebsite, 0},
};
private const int ScrobbleSleepTime = 700; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90)
private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90)
private static readonly IList<ScrobbleProvider> BookProviders = new List<ScrobbleProvider>()
{
@ -425,8 +424,17 @@ public class ScrobblingService : IScrobblingService
if (response.ErrorMessage != null && response.ErrorMessage.Contains("Too Many Requests"))
{
_logger.LogInformation("Hit Too many requests, sleeping to regain requests");
await Task.Delay(TimeSpan.FromMinutes(1));
} else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series"))
await Task.Delay(TimeSpan.FromMinutes(5));
} else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unauthorized"))
{
_logger.LogInformation("Kavita+ responded with Unauthorized. Please check your subscription");
await _licenseService.HasActiveLicense(true);
throw new KavitaException("Kavita+ responded with Unauthorized. Please check your subscription");
} else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Access token is invalid"))
{
throw new KavitaException("Access token is invalid");
}
else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series"))
{
// Log the Series name and Id in ScrobbleErrors
_logger.LogInformation("Kavita+ was unable to match the series");
@ -615,10 +623,7 @@ public class ScrobblingService : IScrobblingService
.Where(e => librariesWithScrobbling.Contains(e.LibraryId))
.Where(e => !errors.Contains(e.SeriesId))
.ToList();
// var reviewEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.Review))
// .Where(e => librariesWithScrobbling.Contains(e.LibraryId))
// .Where(e => !errors.Contains(e.SeriesId))
// .ToList();
var decisions = addToWantToRead
.GroupBy(item => new { item.SeriesId, item.AppUserId })
.Select(group => new
@ -645,7 +650,7 @@ public class ScrobblingService : IScrobblingService
await SetAndCheckRateLimit(userRateLimits, user, license.Value);
}
var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count;// + reviewEvents.Count;
var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count;
_logger.LogInformation("Found {TotalEvents} Scrobble Events", totalProgress);
try
@ -692,22 +697,6 @@ public class ScrobblingService : IScrobblingService
Year = evt.Series.Metadata.ReleaseYear
}));
// progressCounter = await ProcessEvents(reviewEvents, userRateLimits, usersToScrobble.Count, progressCounter,
// totalProgress, evt => Task.FromResult(new ScrobbleDto()
// {
// Format = evt.Format,
// AniListId = evt.AniListId,
// MALId = (int?) evt.MalId,
// ScrobbleEventType = evt.ScrobbleEventType,
// AniListToken = evt.AppUser.AniListAccessToken,
// SeriesName = evt.Series.Name,
// LocalizedSeriesName = evt.Series.LocalizedName,
// Rating = evt.Rating,
// Year = evt.Series.Metadata.ReleaseYear,
// ReviewBody = evt.ReviewBody,
// ReviewTitle = evt.ReviewTitle
// }));
progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter,
totalProgress, evt => Task.FromResult(new ScrobbleDto()
{
@ -766,7 +755,22 @@ public class ScrobblingService : IScrobblingService
{
continue;
}
if (_tokenService.HasTokenExpired(evt.AppUser.AniListAccessToken))
{
_unitOfWork.ScrobbleRepository.Attach(new ScrobbleError()
{
Comment = "AniList token has expired and needs rotating. Scrobbles wont work until then",
Details = $"User: {evt.AppUser.UserName}",
LibraryId = evt.LibraryId,
SeriesId = evt.SeriesId
});
await _unitOfWork.CommitAsync();
return 0;
}
var count = await SetAndCheckRateLimit(userRateLimits, evt.AppUser, license.Value);
userRateLimits[evt.AppUserId] = count;
if (count == 0)
{
if (usersToScrobble == 1) break;
@ -786,6 +790,14 @@ public class ScrobblingService : IScrobblingService
// If a flurl exception occured, the API is likely down. Kill processing
throw;
}
catch (KavitaException ex)
{
if (ex.Message.Contains("Access token is invalid"))
{
_logger.LogCritical("Access Token for UserId: {UserId} needs to be rotated to continue scrobbling", evt.AppUser.Id);
return progressCounter;
}
}
catch (Exception)
{
/* Swallow as it's already been handled in PostScrobbleUpdate */

View file

@ -1,8 +1,11 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.SeriesDetail;
@ -11,6 +14,7 @@ using API.Entities.Enums;
using API.Helpers;
using API.Helpers.Builders;
using API.Services.Plus;
using EasyCaching.Core;
using Flurl.Http;
using HtmlAgilityPack;
using Kavita.Common;
@ -48,18 +52,98 @@ public class ReviewService : IReviewService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReviewService> _logger;
private readonly ILicenseService _licenseService;
private readonly IEasyCachingProvider _cacheProvider;
public const string CacheKey = "review_";
public ReviewService(IUnitOfWork unitOfWork, ILogger<ReviewService> logger)
public ReviewService(IUnitOfWork unitOfWork, ILogger<ReviewService> logger, ILicenseService licenseService,
IEasyCachingProviderFactory cachingProviderFactory)
{
_unitOfWork = unitOfWork;
_logger = logger;
_licenseService = licenseService;
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
}
public async Task<IEnumerable<UserReviewDto>> GetReviewsForSeries(int userId, int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return ImmutableList<UserReviewDto>.Empty;
var userRatings = (await _unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, userId))
.Where(r => !string.IsNullOrEmpty(r.Body))
.OrderByDescending(review => review.Username.Equals(user.UserName) ? 1 : 0)
.ToList();
if (!await _licenseService.HasActiveLicense())
{
return userRatings;
}
var cacheKey = CacheKey + seriesId;
IList<UserReviewDto> externalReviews;
var result = await _cacheProvider.GetAsync<IEnumerable<UserReviewDto>>(cacheKey);
if (result.HasValue)
{
externalReviews = result.Value.ToList();
}
else
{
var reviews = (await GetExternalReviews(userId, seriesId)).ToList();
externalReviews = SelectSpectrumOfReviews(reviews);
await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10));
_logger.LogDebug("Caching external reviews for {Key}", cacheKey);
}
// Fetch external reviews and splice them in
userRatings.AddRange(externalReviews);
return userRatings;
}
private static IList<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
{
IList<UserReviewDto> externalReviews;
var totalReviews = reviews.Count;
if (totalReviews > 10)
{
var stepSize = Math.Max((totalReviews - 4) / 8, 1);
var selectedReviews = new List<UserReviewDto>()
{
reviews[0],
reviews[1],
};
for (var i = 2; i < totalReviews - 2; i += stepSize)
{
selectedReviews.Add(reviews[i]);
if (selectedReviews.Count >= 8)
break;
}
selectedReviews.Add(reviews[totalReviews - 2]);
selectedReviews.Add(reviews[totalReviews - 1]);
externalReviews = selectedReviews;
}
else
{
externalReviews = reviews;
}
return externalReviews;
}
private async Task<IEnumerable<UserReviewDto>> GetExternalReviews(int userId, int seriesId)
{
var series =
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,

View file

@ -19,6 +19,7 @@ public interface ITaskScheduler
Task ScheduleTasks();
Task ScheduleStatsTasks();
void ScheduleUpdaterTasks();
Task ScheduleKavitaPlusTasks();
void ScanFolder(string folderPath, TimeSpan delay);
void ScanFolder(string folderPath);
void ScanLibrary(int libraryId, bool force = false);
@ -72,7 +73,7 @@ public class TaskScheduler : ITaskScheduler
public const string LicenseCheck = "license-check";
private static readonly ImmutableArray<string> ScanTasks =
ImmutableArray.Create("ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries");
["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"];
private static readonly Random Rnd = new Random();
@ -143,11 +144,22 @@ public class TaskScheduler : ITaskScheduler
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions);
await ScheduleKavitaPlusTasks();
}
public async Task ScheduleKavitaPlusTasks()
{
// KavitaPlus based (needs license check)
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
if (!await _licenseService.HasActiveSubscription(license))
{
return;
}
RecurringJob.AddOrUpdate(CheckScrobblingTokens, () => _scrobblingService.CheckExternalAccessTokens(), Cron.Daily, RecurringJobOptions);
BackgroundJob.Enqueue(() => _scrobblingService.CheckExternalAccessTokens()); // We also kick off an immediate check on startup
RecurringJob.AddOrUpdate(LicenseCheck, () => _licenseService.ValidateLicenseStatus(), LicenseService.Cron, RecurringJobOptions);
BackgroundJob.Enqueue(() => _licenseService.ValidateLicenseStatus());
RecurringJob.AddOrUpdate(LicenseCheck, () => _licenseService.HasActiveLicense(true), LicenseService.Cron, RecurringJobOptions);
BackgroundJob.Enqueue(() => _licenseService.HasActiveLicense(true));
// KavitaPlus Scrobbling (every 4 hours)
RecurringJob.AddOrUpdate(ProcessScrobblingEvents, () => _scrobblingService.ProcessUpdatesSinceLastSync(), "0 */4 * * *", RecurringJobOptions);