Kavita+ Enhancements (#2616)
This commit is contained in:
parent
625c56b265
commit
dd44f55747
43 changed files with 1056 additions and 468 deletions
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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>
|
||||
|
|
15
API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs
Normal file
15
API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs
Normal 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; }
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue