mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Customized Scheduler + Saved Kavita+ Details (#2644)
This commit is contained in:
parent
2092e120c3
commit
ad74871623
@ -10,7 +10,8 @@ public class CronConverterTests
|
|||||||
[InlineData("disabled", "0 0 31 2 *")]
|
[InlineData("disabled", "0 0 31 2 *")]
|
||||||
[InlineData("weekly", "0 0 * * 1")]
|
[InlineData("weekly", "0 0 * * 1")]
|
||||||
[InlineData("", "0 0 31 2 *")]
|
[InlineData("", "0 0 31 2 *")]
|
||||||
[InlineData("sdfgdf", "")]
|
[InlineData("sdfgdf", "sdfgdf")]
|
||||||
|
[InlineData("* * * * *", "* * * * *")]
|
||||||
public void ConvertTest(string input, string expected)
|
public void ConvertTest(string input, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, CronConverter.ConvertToCronNotation(input));
|
Assert.Equal(expected, CronConverter.ConvertToCronNotation(input));
|
||||||
|
@ -18,6 +18,7 @@ using API.Services;
|
|||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
using API.Tests.Helpers;
|
using API.Tests.Helpers;
|
||||||
|
using EasyCaching.Core;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.InMemory;
|
using Hangfire.InMemory;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
@ -58,7 +59,8 @@ public class SeriesServiceTests : AbstractDbTest
|
|||||||
|
|
||||||
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
|
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
|
||||||
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
|
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
|
||||||
Substitute.For<IScrobblingService>(), locService);
|
Substitute.For<IScrobblingService>(), locService,
|
||||||
|
Substitute.For<IEasyCachingProviderFactory>());
|
||||||
}
|
}
|
||||||
#region Setup
|
#region Setup
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@
|
|||||||
<PackageReference Include="NetVips.Native" Version="8.15.1" />
|
<PackageReference Include="NetVips.Native" Version="8.15.1" />
|
||||||
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
|
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
|
||||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
|
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
|
||||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
|
||||||
|
@ -15,12 +15,9 @@ public static class EasyCacheProfiles
|
|||||||
/// Cache the libraries on the server
|
/// Cache the libraries on the server
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Library = "library";
|
public const string Library = "library";
|
||||||
/// <summary>
|
|
||||||
/// Metadata filter
|
|
||||||
/// </summary>
|
|
||||||
public const string Filter = "filter";
|
|
||||||
public const string KavitaPlusReviews = "kavita+reviews";
|
|
||||||
public const string KavitaPlusRecommendations = "kavita+recommendations";
|
|
||||||
public const string KavitaPlusRatings = "kavita+ratings";
|
|
||||||
public const string KavitaPlusExternalSeries = "kavita+externalSeries";
|
public const string KavitaPlusExternalSeries = "kavita+externalSeries";
|
||||||
|
/// <summary>
|
||||||
|
/// Series Detail page for Kavita+ stuff
|
||||||
|
/// </summary>
|
||||||
|
public const string KavitaPlusSeriesDetail = "kavita+seriesDetail";
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ using API.Entities.Enums;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
|
using EasyCaching.Core;
|
||||||
using Kavita.Common.Extensions;
|
using Kavita.Common.Extensions;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -21,9 +22,12 @@ namespace API.Controllers;
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILicenseService licenseService,
|
public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILicenseService licenseService,
|
||||||
IRatingService ratingService, IReviewService reviewService, IRecommendationService recommendationService, IExternalMetadataService metadataService)
|
IExternalMetadataService metadataService, IEasyCachingProviderFactory cachingProviderFactory)
|
||||||
: BaseApiController
|
: BaseApiController
|
||||||
{
|
{
|
||||||
|
private readonly IEasyCachingProvider _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusSeriesDetail);
|
||||||
|
public const string CacheKey = "kavitaPlusSeriesDetail_";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches genres from the instance
|
/// Fetches genres from the instance
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -48,7 +52,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
/// <param name="role">role</param>
|
/// <param name="role">role</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("people-by-role")]
|
[HttpGet("people-by-role")]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"role"})]
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["role"])]
|
||||||
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
|
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
|
||||||
{
|
{
|
||||||
return role.HasValue ?
|
return role.HasValue ?
|
||||||
@ -62,7 +66,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
/// <param name="libraryIds">String separated libraryIds or null for all people</param>
|
/// <param name="libraryIds">String separated libraryIds or null for all people</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("people")]
|
[HttpGet("people")]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds"])]
|
||||||
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(string? libraryIds)
|
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(string? libraryIds)
|
||||||
{
|
{
|
||||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||||
@ -79,7 +83,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
/// <param name="libraryIds">String separated libraryIds or null for all tags</param>
|
/// <param name="libraryIds">String separated libraryIds or null for all tags</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("tags")]
|
[HttpGet("tags")]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds"])]
|
||||||
public async Task<ActionResult<IList<TagDto>>> GetAllTags(string? libraryIds)
|
public async Task<ActionResult<IList<TagDto>>> GetAllTags(string? libraryIds)
|
||||||
{
|
{
|
||||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||||
@ -96,7 +100,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
|
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
|
||||||
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
|
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})]
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])]
|
||||||
[HttpGet("age-ratings")]
|
[HttpGet("age-ratings")]
|
||||||
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
|
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
|
||||||
{
|
{
|
||||||
@ -184,6 +188,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches the details needed from Kavita+ for Series Detail page
|
/// Fetches the details needed from Kavita+ for Series Detail page
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>This will hit upstream K+ if the data in local db is 2 weeks old</remarks>
|
||||||
/// <param name="seriesId"></param>
|
/// <param name="seriesId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("series-detail-plus")]
|
[HttpGet("series-detail-plus")]
|
||||||
@ -195,7 +200,32 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
return Ok(null);
|
return Ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(await metadataService.GetSeriesDetail(User.GetUserId(), seriesId));
|
var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
var userReviews = (await unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, user.Id))
|
||||||
|
.Where(r => !string.IsNullOrEmpty(r.Body))
|
||||||
|
.OrderByDescending(review => review.Username.Equals(user.UserName) ? 1 : 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var cacheKey = CacheKey + seriesId + "_" + user.Id;
|
||||||
|
var results = await _cacheProvider.GetAsync<SeriesDetailPlusDto>(cacheKey);
|
||||||
|
if (results.HasValue)
|
||||||
|
{
|
||||||
|
var cachedResult = results.Value;
|
||||||
|
userReviews.AddRange(cachedResult.Reviews);
|
||||||
|
cachedResult.Reviews = ReviewService.SelectSpectrumOfReviews(userReviews);
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret = await metadataService.GetSeriesDetail(user.Id, seriesId);
|
||||||
|
if (ret == null) return Ok(null);
|
||||||
|
userReviews.AddRange(ret.Reviews);
|
||||||
|
ret.Reviews = ReviewService.SelectSpectrumOfReviews(userReviews);
|
||||||
|
|
||||||
|
|
||||||
|
await _cacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromHours(24));
|
||||||
|
|
||||||
|
return Ok(ret);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,38 +20,12 @@ namespace API.Controllers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class RatingController : BaseApiController
|
public class RatingController : BaseApiController
|
||||||
{
|
{
|
||||||
private readonly ILicenseService _licenseService;
|
|
||||||
private readonly IRatingService _ratingService;
|
|
||||||
private readonly ILogger<RatingController> _logger;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IEasyCachingProvider _cacheProvider;
|
|
||||||
public const string CacheKey = "rating_";
|
|
||||||
|
|
||||||
public RatingController(ILicenseService licenseService, IRatingService ratingService,
|
public RatingController(IUnitOfWork unitOfWork)
|
||||||
ILogger<RatingController> logger, IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork)
|
|
||||||
{
|
{
|
||||||
_licenseService = licenseService;
|
|
||||||
_ratingService = ratingService;
|
|
||||||
_logger = logger;
|
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
|
|
||||||
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the external ratings for a given series
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="seriesId"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
[HttpGet]
|
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
|
|
||||||
public async Task<ActionResult<IEnumerable<RatingDto>>> GetRating(int seriesId)
|
|
||||||
{
|
|
||||||
if (!await _licenseService.HasActiveLicense())
|
|
||||||
{
|
|
||||||
return Ok(Enumerable.Empty<RatingDto>());
|
|
||||||
}
|
|
||||||
return Ok(await _ratingService.GetRatings(seriesId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("overall")]
|
[HttpGet("overall")]
|
||||||
|
@ -1,19 +1,9 @@
|
|||||||
using System;
|
using System.Threading.Tasks;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using API.Constants;
|
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Recommendation;
|
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Services;
|
|
||||||
using API.Services.Plus;
|
|
||||||
using EasyCaching.Core;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace API.Controllers;
|
namespace API.Controllers;
|
||||||
|
|
||||||
@ -22,56 +12,14 @@ namespace API.Controllers;
|
|||||||
public class RecommendedController : BaseApiController
|
public class RecommendedController : BaseApiController
|
||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IRecommendationService _recommendationService;
|
|
||||||
private readonly ILicenseService _licenseService;
|
|
||||||
private readonly ILocalizationService _localizationService;
|
|
||||||
private readonly IEasyCachingProvider _cacheProvider;
|
|
||||||
public const string CacheKey = "recommendation_";
|
public const string CacheKey = "recommendation_";
|
||||||
|
|
||||||
public RecommendedController(IUnitOfWork unitOfWork, IRecommendationService recommendationService,
|
public RecommendedController(IUnitOfWork unitOfWork)
|
||||||
ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory,
|
|
||||||
ILocalizationService localizationService)
|
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_recommendationService = recommendationService;
|
|
||||||
_licenseService = licenseService;
|
|
||||||
_localizationService = localizationService;
|
|
||||||
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For Kavita+ users, this will return recommendations on the server.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="seriesId"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
[HttpGet("recommendations")]
|
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
|
|
||||||
public async Task<ActionResult<RecommendationDto>> GetRecommendations(int seriesId)
|
|
||||||
{
|
|
||||||
var userId = User.GetUserId();
|
|
||||||
if (!await _licenseService.HasActiveLicense())
|
|
||||||
{
|
|
||||||
return Ok(new RecommendationDto());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await _unitOfWork.UserRepository.HasAccessToSeries(userId, seriesId))
|
|
||||||
{
|
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-restricted"));
|
|
||||||
}
|
|
||||||
|
|
||||||
var cacheKey = $"{CacheKey}-{seriesId}-{userId}";
|
|
||||||
var results = await _cacheProvider.GetAsync<RecommendationDto>(cacheKey);
|
|
||||||
if (results.HasValue)
|
|
||||||
{
|
|
||||||
return Ok(results.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
var ret = await _recommendationService.GetRecommendationsForSeries(userId, seriesId);
|
|
||||||
await _cacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromHours(10));
|
|
||||||
return Ok(ret);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release.
|
/// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -79,7 +27,7 @@ public class RecommendedController : BaseApiController
|
|||||||
/// <param name="userParams">Pagination</param>
|
/// <param name="userParams">Pagination</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("quick-reads")]
|
[HttpGet("quick-reads")]
|
||||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams)
|
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickReads(int libraryId, [FromQuery] UserParams? userParams)
|
||||||
{
|
{
|
||||||
userParams ??= UserParams.Default;
|
userParams ??= UserParams.Default;
|
||||||
var series = await _unitOfWork.SeriesRepository.GetQuickReads(User.GetUserId(), libraryId, userParams);
|
var series = await _unitOfWork.SeriesRepository.GetQuickReads(User.GetUserId(), libraryId, userParams);
|
||||||
@ -95,7 +43,7 @@ public class RecommendedController : BaseApiController
|
|||||||
/// <param name="userParams"></param>
|
/// <param name="userParams"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("quick-catchup-reads")]
|
[HttpGet("quick-catchup-reads")]
|
||||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams userParams)
|
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams? userParams)
|
||||||
{
|
{
|
||||||
userParams ??= UserParams.Default;
|
userParams ??= UserParams.Default;
|
||||||
var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(User.GetUserId(), libraryId, userParams);
|
var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(User.GetUserId(), libraryId, userParams);
|
||||||
@ -111,7 +59,7 @@ public class RecommendedController : BaseApiController
|
|||||||
/// <param name="userParams">Pagination</param>
|
/// <param name="userParams">Pagination</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("highly-rated")]
|
[HttpGet("highly-rated")]
|
||||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams)
|
public async Task<ActionResult<PagedList<SeriesDto>>> GetHighlyRated(int libraryId, [FromQuery] UserParams? userParams)
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
userParams ??= UserParams.Default;
|
userParams ??= UserParams.Default;
|
||||||
@ -129,7 +77,7 @@ public class RecommendedController : BaseApiController
|
|||||||
/// <param name="userParams">Pagination</param>
|
/// <param name="userParams">Pagination</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("more-in")]
|
[HttpGet("more-in")]
|
||||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams)
|
public async Task<ActionResult<PagedList<SeriesDto>>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams? userParams)
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
|
|
||||||
@ -148,7 +96,7 @@ public class RecommendedController : BaseApiController
|
|||||||
/// <param name="userParams">Pagination</param>
|
/// <param name="userParams">Pagination</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("rediscover")]
|
[HttpGet("rediscover")]
|
||||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetRediscover(int libraryId, [FromQuery] UserParams userParams)
|
public async Task<ActionResult<PagedList<SeriesDto>>> GetRediscover(int libraryId, [FromQuery] UserParams? userParams)
|
||||||
{
|
{
|
||||||
userParams ??= UserParams.Default;
|
userParams ??= UserParams.Default;
|
||||||
var series = await _unitOfWork.SeriesRepository.GetRediscover(User.GetUserId(), libraryId, userParams);
|
var series = await _unitOfWork.SeriesRepository.GetRediscover(User.GetUserId(), libraryId, userParams);
|
||||||
|
@ -1,20 +1,14 @@
|
|||||||
using System;
|
using System.Linq;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
using API.Services;
|
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using EasyCaching.Core;
|
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace API.Controllers;
|
namespace API.Controllers;
|
||||||
|
|
||||||
@ -22,41 +16,19 @@ namespace API.Controllers;
|
|||||||
|
|
||||||
public class ReviewController : BaseApiController
|
public class ReviewController : BaseApiController
|
||||||
{
|
{
|
||||||
private readonly ILogger<ReviewController> _logger;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ILicenseService _licenseService;
|
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
private readonly IReviewService _reviewService;
|
|
||||||
private readonly IScrobblingService _scrobblingService;
|
private readonly IScrobblingService _scrobblingService;
|
||||||
private readonly IEasyCachingProvider _cacheProvider;
|
|
||||||
public const string CacheKey = "review_";
|
|
||||||
|
|
||||||
public ReviewController(ILogger<ReviewController> logger, IUnitOfWork unitOfWork, ILicenseService licenseService,
|
public ReviewController(IUnitOfWork unitOfWork,
|
||||||
IMapper mapper, IReviewService reviewService, IScrobblingService scrobblingService,
|
IMapper mapper, IScrobblingService scrobblingService)
|
||||||
IEasyCachingProviderFactory cachingProviderFactory)
|
|
||||||
{
|
{
|
||||||
_logger = logger;
|
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_licenseService = licenseService;
|
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
_reviewService = reviewService;
|
|
||||||
_scrobblingService = scrobblingService;
|
_scrobblingService = scrobblingService;
|
||||||
|
|
||||||
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetches reviews from the server for a given series
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="seriesId"></param>
|
|
||||||
[HttpGet]
|
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
|
|
||||||
public async Task<ActionResult<IEnumerable<UserReviewDto>>> GetReviews(int seriesId)
|
|
||||||
{
|
|
||||||
return Ok(await _reviewService.GetReviewsForSeries(User.GetUserId(), seriesId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates the review for a given series
|
/// Updates the review for a given series
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
@ -39,16 +38,14 @@ public class SeriesController : BaseApiController
|
|||||||
private readonly ILicenseService _licenseService;
|
private readonly ILicenseService _licenseService;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
private readonly IExternalMetadataService _externalMetadataService;
|
private readonly IExternalMetadataService _externalMetadataService;
|
||||||
private readonly IEasyCachingProvider _ratingCacheProvider;
|
|
||||||
private readonly IEasyCachingProvider _reviewCacheProvider;
|
|
||||||
private readonly IEasyCachingProvider _recommendationCacheProvider;
|
|
||||||
private readonly IEasyCachingProvider _externalSeriesCacheProvider;
|
private readonly IEasyCachingProvider _externalSeriesCacheProvider;
|
||||||
private const string CacheKey = "recommendation_";
|
private const string CacheKey = "externalSeriesData_";
|
||||||
|
|
||||||
|
|
||||||
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork,
|
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork,
|
||||||
ISeriesService seriesService, ILicenseService licenseService,
|
ISeriesService seriesService, ILicenseService licenseService,
|
||||||
IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
|
IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService,
|
||||||
|
IExternalMetadataService externalMetadataService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_taskScheduler = taskScheduler;
|
_taskScheduler = taskScheduler;
|
||||||
@ -58,9 +55,6 @@ public class SeriesController : BaseApiController
|
|||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
_externalMetadataService = externalMetadataService;
|
_externalMetadataService = externalMetadataService;
|
||||||
|
|
||||||
_ratingCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
|
|
||||||
_reviewCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
|
|
||||||
_recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
|
|
||||||
_externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
|
_externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -451,19 +445,6 @@ public class SeriesController : BaseApiController
|
|||||||
if (!await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
|
if (!await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "update-metadata-fail"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "update-metadata-fail"));
|
||||||
|
|
||||||
if (await _licenseService.HasActiveLicense())
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Clearing cache as series weblinks may have changed");
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
await _recommendationCacheProvider.RemoveAsync(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(await _localizationService.Translate(User.GetUserId(), "series-updated"));
|
return Ok(await _localizationService.Translate(User.GetUserId(), "series-updated"));
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -605,7 +586,7 @@ public class SeriesController : BaseApiController
|
|||||||
await _externalSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(15));
|
await _externalSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(15));
|
||||||
return Ok(ret);
|
return Ok(ret);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
return BadRequest("Unable to load External Series details");
|
return BadRequest("Unable to load External Series details");
|
||||||
}
|
}
|
||||||
|
@ -264,13 +264,9 @@ public class ServerController : BaseApiController
|
|||||||
public async Task<ActionResult> BustReviewAndRecCache()
|
public async Task<ActionResult> BustReviewAndRecCache()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Busting Kavita+ Cache");
|
_logger.LogInformation("Busting Kavita+ Cache");
|
||||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
|
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
|
||||||
await provider.FlushAsync();
|
await provider.FlushAsync();
|
||||||
provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
|
provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusSeriesDetail);
|
||||||
await provider.FlushAsync();
|
|
||||||
provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
|
|
||||||
await provider.FlushAsync();
|
|
||||||
provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
|
|
||||||
await provider.FlushAsync();
|
await provider.FlushAsync();
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,8 @@ using API.Logging;
|
|||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Tasks.Scanner;
|
using API.Services.Tasks.Scanner;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Flurl.Http;
|
using Cronos;
|
||||||
|
using Hangfire;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Kavita.Common.EnvironmentInfo;
|
using Kavita.Common.EnvironmentInfo;
|
||||||
using Kavita.Common.Extensions;
|
using Kavita.Common.Extensions;
|
||||||
@ -59,6 +60,10 @@ public class SettingsController : BaseApiController
|
|||||||
return Ok(settingsDto.BaseUrl);
|
return Ok(settingsDto.BaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the server settings
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
[Authorize(Policy = "RequireAdminRole")]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<ServerSettingDto>> GetSettings()
|
public async Task<ActionResult<ServerSettingDto>> GetSettings()
|
||||||
@ -161,7 +166,8 @@ public class SettingsController : BaseApiController
|
|||||||
if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") &&
|
if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") &&
|
||||||
!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/"))
|
!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/"))
|
||||||
{
|
{
|
||||||
bookmarkDirectory = _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks");
|
bookmarkDirectory =
|
||||||
|
_directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory))
|
if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory))
|
||||||
@ -171,42 +177,29 @@ public class SettingsController : BaseApiController
|
|||||||
|
|
||||||
foreach (var setting in currentSettings)
|
foreach (var setting in currentSettings)
|
||||||
{
|
{
|
||||||
if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value)
|
UpdateSchedulingSettings(setting, updateSettingsDto);
|
||||||
{
|
|
||||||
setting.Value = updateSettingsDto.TaskBackup;
|
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value)
|
if (setting.Key == ServerSettingKey.OnDeckProgressDays &&
|
||||||
{
|
updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value)
|
||||||
setting.Value = updateSettingsDto.TaskScan;
|
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.OnDeckProgressDays && updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value)
|
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty;
|
setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.OnDeckUpdateDays && updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.OnDeckUpdateDays &&
|
||||||
|
updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty;
|
setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.CoverImageSize && updateSettingsDto.CoverImageSize + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.CoverImageSize &&
|
||||||
|
updateSettingsDto.CoverImageSize + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.CoverImageSize + string.Empty;
|
setting.Value = updateSettingsDto.CoverImageSize + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_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 (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
if (OsInfo.IsDocker) continue;
|
if (OsInfo.IsDocker) continue;
|
||||||
@ -216,7 +209,8 @@ public class SettingsController : BaseApiController
|
|||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.CacheSize && updateSettingsDto.CacheSize + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.CacheSize &&
|
||||||
|
updateSettingsDto.CacheSize + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.CacheSize + string.Empty;
|
setting.Value = updateSettingsDto.CacheSize + string.Empty;
|
||||||
// CacheSize is managed in appSetting.json
|
// CacheSize is managed in appSetting.json
|
||||||
@ -232,10 +226,13 @@ public class SettingsController : BaseApiController
|
|||||||
{
|
{
|
||||||
if (OsInfo.IsDocker) continue;
|
if (OsInfo.IsDocker) continue;
|
||||||
// Validate IP addresses
|
// Validate IP addresses
|
||||||
foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',',
|
||||||
|
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||||
{
|
{
|
||||||
if (!IPAddress.TryParse(ipAddress.Trim(), out _)) {
|
if (!IPAddress.TryParse(ipAddress.Trim(), out _))
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid", ipAddress));
|
{
|
||||||
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid",
|
||||||
|
ipAddress));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,20 +255,23 @@ public class SettingsController : BaseApiController
|
|||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.LoggingLevel && updateSettingsDto.LoggingLevel + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.LoggingLevel &&
|
||||||
|
updateSettingsDto.LoggingLevel + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.LoggingLevel + string.Empty;
|
setting.Value = updateSettingsDto.LoggingLevel + string.Empty;
|
||||||
LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel);
|
LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel);
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EnableOpds && updateSettingsDto.EnableOpds + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EnableOpds &&
|
||||||
|
updateSettingsDto.EnableOpds + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.EnableOpds + string.Empty;
|
setting.Value = updateSettingsDto.EnableOpds + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EncodeMediaAs && updateSettingsDto.EncodeMediaAs + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EncodeMediaAs &&
|
||||||
|
updateSettingsDto.EncodeMediaAs + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.EncodeMediaAs + string.Empty;
|
setting.Value = updateSettingsDto.EncodeMediaAs + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
@ -289,7 +289,8 @@ public class SettingsController : BaseApiController
|
|||||||
// Validate new directory can be used
|
// Validate new directory can be used
|
||||||
if (!await _directoryService.CheckWriteAccess(bookmarkDirectory))
|
if (!await _directoryService.CheckWriteAccess(bookmarkDirectory))
|
||||||
{
|
{
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions"));
|
return BadRequest(
|
||||||
|
await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions"));
|
||||||
}
|
}
|
||||||
|
|
||||||
originalBookmarkDirectory = setting.Value;
|
originalBookmarkDirectory = setting.Value;
|
||||||
@ -300,7 +301,8 @@ public class SettingsController : BaseApiController
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.AllowStatCollection &&
|
||||||
|
updateSettingsDto.AllowStatCollection + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.AllowStatCollection + string.Empty;
|
setting.Value = updateSettingsDto.AllowStatCollection + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
@ -314,27 +316,32 @@ public class SettingsController : BaseApiController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.TotalBackups && updateSettingsDto.TotalBackups + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.TotalBackups &&
|
||||||
|
updateSettingsDto.TotalBackups + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1)
|
if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1)
|
||||||
{
|
{
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-backups"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-backups"));
|
||||||
}
|
}
|
||||||
|
|
||||||
setting.Value = updateSettingsDto.TotalBackups + string.Empty;
|
setting.Value = updateSettingsDto.TotalBackups + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.TotalLogs && updateSettingsDto.TotalLogs + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.TotalLogs &&
|
||||||
|
updateSettingsDto.TotalLogs + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1)
|
if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1)
|
||||||
{
|
{
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-logs"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-logs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
setting.Value = updateSettingsDto.TotalLogs + string.Empty;
|
setting.Value = updateSettingsDto.TotalLogs + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EnableFolderWatching && updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EnableFolderWatching &&
|
||||||
|
updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty;
|
setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
@ -376,63 +383,97 @@ public class SettingsController : BaseApiController
|
|||||||
return Ok(updateSettingsDto);
|
return Ok(updateSettingsDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
|
||||||
|
{
|
||||||
|
if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value)
|
||||||
|
{
|
||||||
|
setting.Value = updateSettingsDto.TaskBackup;
|
||||||
|
_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.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value)
|
||||||
|
{
|
||||||
|
setting.Value = updateSettingsDto.TaskCleanup;
|
||||||
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
|
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
|
||||||
{
|
{
|
||||||
if (setting.Key == ServerSettingKey.EmailHost && updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EmailHost &&
|
||||||
|
updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty;
|
setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EmailPort && updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EmailPort &&
|
||||||
|
updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty;
|
setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EmailAuthPassword && updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EmailAuthPassword &&
|
||||||
|
updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty;
|
setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EmailAuthUserName && updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EmailAuthUserName &&
|
||||||
|
updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty;
|
setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EmailSenderAddress && updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EmailSenderAddress &&
|
||||||
|
updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty;
|
setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EmailSenderDisplayName && updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EmailSenderDisplayName &&
|
||||||
|
updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty;
|
setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EmailSizeLimit && updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EmailSizeLimit &&
|
||||||
|
updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty;
|
setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EmailEnableSsl && updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EmailEnableSsl &&
|
||||||
|
updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty;
|
setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.EmailCustomizedTemplates &&
|
||||||
|
updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty;
|
setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty;
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
[Authorize(Policy = "RequireAdminRole")]
|
||||||
[HttpGet("task-frequencies")]
|
[HttpGet("task-frequencies")]
|
||||||
public ActionResult<IEnumerable<string>> GetTaskFrequencies()
|
public ActionResult<IEnumerable<string>> GetTaskFrequencies()
|
||||||
@ -451,7 +492,7 @@ public class SettingsController : BaseApiController
|
|||||||
[HttpGet("log-levels")]
|
[HttpGet("log-levels")]
|
||||||
public ActionResult<IEnumerable<string>> GetLogLevels()
|
public ActionResult<IEnumerable<string>> GetLogLevels()
|
||||||
{
|
{
|
||||||
return Ok(new [] {"Trace", "Debug", "Information", "Warning", "Critical"});
|
return Ok(new[] {"Trace", "Debug", "Information", "Warning", "Critical"});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("opds-enabled")]
|
[HttpGet("opds-enabled")]
|
||||||
@ -460,4 +501,16 @@ public class SettingsController : BaseApiController
|
|||||||
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||||
return Ok(settingsDto.EnableOpds);
|
return Ok(settingsDto.EnableOpds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is the cron expression valid for Kavita's scheduler
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cronExpression"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("is-valid-cron")]
|
||||||
|
public ActionResult<bool> IsValidCron(string cronExpression)
|
||||||
|
{
|
||||||
|
// NOTE: This must match Hangfire's underlying cron system. Hangfire is unique
|
||||||
|
return Ok(CronHelper.IsValidCron(cronExpression));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,5 +45,9 @@ public enum FilterField
|
|||||||
/// Last time User Read
|
/// Last time User Read
|
||||||
/// </summary>
|
/// </summary>
|
||||||
ReadingDate = 27,
|
ReadingDate = 27,
|
||||||
|
/// <summary>
|
||||||
|
/// Average rating from Kavita+ - Not usable for non-licensed users
|
||||||
|
/// </summary>
|
||||||
|
AverageRating = 28
|
||||||
|
|
||||||
}
|
}
|
||||||
|
17
API/DTOs/Scrobbling/MediaRecommendationDto.cs
Normal file
17
API/DTOs/Scrobbling/MediaRecommendationDto.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Services.Plus;
|
||||||
|
|
||||||
|
namespace API.DTOs.Scrobbling;
|
||||||
|
|
||||||
|
public record MediaRecommendationDto
|
||||||
|
{
|
||||||
|
public int Rating { get; set; }
|
||||||
|
public IEnumerable<string> RecommendationNames { get; set; } = null!;
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string CoverUrl { get; set; }
|
||||||
|
public string SiteUrl { get; set; }
|
||||||
|
public string? Summary { get; set; }
|
||||||
|
public int? AniListId { get; set; }
|
||||||
|
public long? MalId { get; set; }
|
||||||
|
public ScrobbleProvider Provider { get; set; }
|
||||||
|
}
|
21
API/DTOs/Scrobbling/PlusSeriesDto.cs
Normal file
21
API/DTOs/Scrobbling/PlusSeriesDto.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
namespace API.DTOs.Scrobbling;
|
||||||
|
|
||||||
|
public record PlusSeriesDto
|
||||||
|
{
|
||||||
|
public int? AniListId { get; set; }
|
||||||
|
public long? MalId { get; set; }
|
||||||
|
public string? GoogleBooksId { get; set; }
|
||||||
|
public string? MangaDexId { get; set; }
|
||||||
|
public string SeriesName { get; set; }
|
||||||
|
public string? AltSeriesName { get; set; }
|
||||||
|
public MediaFormat MediaFormat { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Optional but can help with matching
|
||||||
|
/// </summary>
|
||||||
|
public int? ChapterCount { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Optional but can help with matching
|
||||||
|
/// </summary>
|
||||||
|
public int? VolumeCount { get; set; }
|
||||||
|
public int? Year { get; set; }
|
||||||
|
}
|
@ -14,27 +14,29 @@ public class UserReviewDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>This is not possible to set as a local user</remarks>
|
/// <remarks>This is not possible to set as a local user</remarks>
|
||||||
public string? Tagline { get; set; }
|
public string? Tagline { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The main review
|
/// The main review
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Body { get; set; }
|
public string Body { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The main body with just text, for review preview
|
||||||
|
/// </summary>
|
||||||
|
public string? BodyJustText { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The series this is for
|
/// The series this is for
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SeriesId { get; set; }
|
public int SeriesId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The library this series belongs in
|
/// The library this series belongs in
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int LibraryId { get; set; }
|
public int LibraryId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user who wrote this
|
/// The user who wrote this
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Username { get; set; }
|
public string Username { get; set; }
|
||||||
|
public int TotalVotes { get; set; }
|
||||||
|
public float Rating { get; set; }
|
||||||
|
public string? RawBody { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How many upvotes this review has gotten
|
/// How many upvotes this review has gotten
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -43,16 +45,11 @@ public class UserReviewDto
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// If External, the url of the review
|
/// If External, the url of the review
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? ExternalUrl { get; set; }
|
public string? SiteUrl { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Does this review come from an external Source
|
/// Does this review come from an external Source
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsExternal { get; set; }
|
public bool IsExternal { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// The main body with just text, for review preview
|
|
||||||
/// </summary>
|
|
||||||
public string? BodyJustText { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// If this review is External, which Provider did it come from
|
/// If this review is External, which Provider did it come from
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -8,11 +8,12 @@ public class ServerSettingDto
|
|||||||
|
|
||||||
public string CacheDirectory { get; set; } = default!;
|
public string CacheDirectory { get; set; } = default!;
|
||||||
public string TaskScan { get; set; } = default!;
|
public string TaskScan { get; set; } = default!;
|
||||||
|
public string TaskBackup { get; set; } = default!;
|
||||||
|
public string TaskCleanup { get; set; } = default!;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logging level for server. Managed in appsettings.json.
|
/// Logging level for server. Managed in appsettings.json.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string LoggingLevel { get; set; } = default!;
|
public string LoggingLevel { get; set; } = default!;
|
||||||
public string TaskBackup { get; set; } = default!;
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Port the server listens on. Managed in appsettings.json.
|
/// Port the server listens on. Managed in appsettings.json.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -58,6 +58,10 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
public DbSet<AppUserDashboardStream> AppUserDashboardStream { get; set; } = null!;
|
public DbSet<AppUserDashboardStream> AppUserDashboardStream { get; set; } = null!;
|
||||||
public DbSet<AppUserSideNavStream> AppUserSideNavStream { get; set; } = null!;
|
public DbSet<AppUserSideNavStream> AppUserSideNavStream { get; set; } = null!;
|
||||||
public DbSet<AppUserExternalSource> AppUserExternalSource { get; set; } = null!;
|
public DbSet<AppUserExternalSource> AppUserExternalSource { get; set; } = null!;
|
||||||
|
public DbSet<ExternalReview> ExternalReview { get; set; } = null!;
|
||||||
|
public DbSet<ExternalRating> ExternalRating { get; set; } = null!;
|
||||||
|
public DbSet<ExternalSeriesMetadata> ExternalSeriesMetadata { get; set; } = null!;
|
||||||
|
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
|
||||||
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
2787
API/Data/Migrations/20240121223643_ExternalSeriesMetadata.Designer.cs
generated
Normal file
2787
API/Data/Migrations/20240121223643_ExternalSeriesMetadata.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
227
API/Data/Migrations/20240121223643_ExternalSeriesMetadata.cs
Normal file
227
API/Data/Migrations/20240121223643_ExternalSeriesMetadata.cs
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExternalSeriesMetadata : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExternalRating",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
AverageScore = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
FavoriteCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Provider = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
ProviderUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
SeriesId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExternalRating", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExternalRecommendation",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Url = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
AniListId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
MalId = table.Column<long>(type: "INTEGER", nullable: true),
|
||||||
|
Provider = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
SeriesId = table.Column<int>(type: "INTEGER", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExternalRecommendation", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExternalRecommendation_Series_SeriesId",
|
||||||
|
column: x => x.SeriesId,
|
||||||
|
principalTable: "Series",
|
||||||
|
principalColumn: "Id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExternalReview",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Tagline = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Body = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
BodyJustText = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
RawBody = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Provider = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
SiteUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Username = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Rating = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Score = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TotalVotes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
SeriesId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExternalReview", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExternalSeriesMetadata",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
AverageExternalRating = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
AniListId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
MalId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
GoogleBooksId = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
LastUpdatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
SeriesId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExternalSeriesMetadata", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExternalSeriesMetadata_Series_SeriesId",
|
||||||
|
column: x => x.SeriesId,
|
||||||
|
principalTable: "Series",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExternalRatingExternalSeriesMetadata",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExternalRatingsId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
ExternalSeriesMetadatasId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExternalRatingExternalSeriesMetadata", x => new { x.ExternalRatingsId, x.ExternalSeriesMetadatasId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExternalRatingExternalSeriesMetadata_ExternalRating_ExternalRatingsId",
|
||||||
|
column: x => x.ExternalRatingsId,
|
||||||
|
principalTable: "ExternalRating",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExternalRatingExternalSeriesMetadata_ExternalSeriesMetadata_ExternalSeriesMetadatasId",
|
||||||
|
column: x => x.ExternalSeriesMetadatasId,
|
||||||
|
principalTable: "ExternalSeriesMetadata",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExternalRecommendationExternalSeriesMetadata",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExternalRecommendationsId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
ExternalSeriesMetadatasId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExternalRecommendationExternalSeriesMetadata", x => new { x.ExternalRecommendationsId, x.ExternalSeriesMetadatasId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExternalRecommendationExternalSeriesMetadata_ExternalRecommendation_ExternalRecommendationsId",
|
||||||
|
column: x => x.ExternalRecommendationsId,
|
||||||
|
principalTable: "ExternalRecommendation",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExternalRecommendationExternalSeriesMetadata_ExternalSeriesMetadata_ExternalSeriesMetadatasId",
|
||||||
|
column: x => x.ExternalSeriesMetadatasId,
|
||||||
|
principalTable: "ExternalSeriesMetadata",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExternalReviewExternalSeriesMetadata",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExternalReviewsId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
ExternalSeriesMetadatasId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExternalReviewExternalSeriesMetadata", x => new { x.ExternalReviewsId, x.ExternalSeriesMetadatasId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExternalReviewExternalSeriesMetadata_ExternalReview_ExternalReviewsId",
|
||||||
|
column: x => x.ExternalReviewsId,
|
||||||
|
principalTable: "ExternalReview",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExternalReviewExternalSeriesMetadata_ExternalSeriesMetadata_ExternalSeriesMetadatasId",
|
||||||
|
column: x => x.ExternalSeriesMetadatasId,
|
||||||
|
principalTable: "ExternalSeriesMetadata",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExternalRatingExternalSeriesMetadata_ExternalSeriesMetadatasId",
|
||||||
|
table: "ExternalRatingExternalSeriesMetadata",
|
||||||
|
column: "ExternalSeriesMetadatasId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExternalRecommendation_SeriesId",
|
||||||
|
table: "ExternalRecommendation",
|
||||||
|
column: "SeriesId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExternalRecommendationExternalSeriesMetadata_ExternalSeriesMetadatasId",
|
||||||
|
table: "ExternalRecommendationExternalSeriesMetadata",
|
||||||
|
column: "ExternalSeriesMetadatasId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExternalReviewExternalSeriesMetadata_ExternalSeriesMetadatasId",
|
||||||
|
table: "ExternalReviewExternalSeriesMetadata",
|
||||||
|
column: "ExternalSeriesMetadatasId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExternalSeriesMetadata_SeriesId",
|
||||||
|
table: "ExternalSeriesMetadata",
|
||||||
|
column: "SeriesId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExternalRatingExternalSeriesMetadata");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExternalRecommendationExternalSeriesMetadata");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExternalReviewExternalSeriesMetadata");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExternalRating");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExternalRecommendation");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExternalReview");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExternalSeriesMetadata");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.13");
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.1");
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||||
{
|
{
|
||||||
@ -1015,6 +1015,145 @@ namespace API.Data.Migrations
|
|||||||
b.ToTable("MediaError");
|
b.ToTable("MediaError");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("AverageScore")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("FavoriteCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Provider")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderUrl")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("ExternalRating");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("AniListId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("CoverUrl")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("MalId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Provider")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("ExternalRecommendation");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("BodyJustText")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Provider")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Rating")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("RawBody")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SiteUrl")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Tagline")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TotalVotes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("ExternalReview");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("AniListId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("AverageExternalRating")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("GoogleBooksId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("MalId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ExternalSeriesMetadata");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@ -1742,6 +1881,51 @@ namespace API.Data.Migrations
|
|||||||
b.ToTable("CollectionTagSeriesMetadata");
|
b.ToTable("CollectionTagSeriesMetadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ExternalRatingsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ExternalSeriesMetadatasId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalSeriesMetadatasId");
|
||||||
|
|
||||||
|
b.ToTable("ExternalRatingExternalSeriesMetadata");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ExternalRecommendationsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ExternalSeriesMetadatasId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalSeriesMetadatasId");
|
||||||
|
|
||||||
|
b.ToTable("ExternalRecommendationExternalSeriesMetadata");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ExternalReviewsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ExternalSeriesMetadatasId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalSeriesMetadatasId");
|
||||||
|
|
||||||
|
b.ToTable("ExternalReviewExternalSeriesMetadata");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("GenresId")
|
b.Property<int>("GenresId")
|
||||||
@ -2128,6 +2312,26 @@ namespace API.Data.Migrations
|
|||||||
b.Navigation("Chapter");
|
b.Navigation("Chapter");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Entities.Series", "Series")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SeriesId");
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Entities.Series", "Series")
|
||||||
|
.WithOne("ExternalSeriesMetadata")
|
||||||
|
.HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.Series", "Series")
|
b.HasOne("API.Entities.Series", "Series")
|
||||||
@ -2368,6 +2572,51 @@ namespace API.Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Entities.Metadata.ExternalRating", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ExternalRatingsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ExternalSeriesMetadatasId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Entities.Metadata.ExternalRecommendation", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ExternalRecommendationsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ExternalSeriesMetadatasId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Entities.Metadata.ExternalReview", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ExternalReviewsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ExternalSeriesMetadatasId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.Genre", null)
|
b.HasOne("API.Entities.Genre", null)
|
||||||
@ -2510,6 +2759,8 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Series", b =>
|
modelBuilder.Entity("API.Entities.Series", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("ExternalSeriesMetadata");
|
||||||
|
|
||||||
b.Navigation("Metadata");
|
b.Navigation("Metadata");
|
||||||
|
|
||||||
b.Navigation("Progress");
|
b.Navigation("Progress");
|
||||||
|
171
API/Data/Repositories/ExternalSeriesMetadataRepository.cs
Normal file
171
API/Data/Repositories/ExternalSeriesMetadataRepository.cs
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using API.Constants;
|
||||||
|
using API.DTOs;
|
||||||
|
using API.DTOs.Recommendation;
|
||||||
|
using API.DTOs.SeriesDetail;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
using API.Entities.Metadata;
|
||||||
|
using API.Extensions;
|
||||||
|
using API.Extensions.QueryExtensions;
|
||||||
|
using AutoMapper;
|
||||||
|
using AutoMapper.QueryableExtensions;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Data.Repositories;
|
||||||
|
|
||||||
|
public interface IExternalSeriesMetadataRepository
|
||||||
|
{
|
||||||
|
void Attach(ExternalSeriesMetadata metadata);
|
||||||
|
void Attach(ExternalRating rating);
|
||||||
|
void Attach(ExternalReview review);
|
||||||
|
void Remove(IEnumerable<ExternalReview>? reviews);
|
||||||
|
void Remove(IEnumerable<ExternalRating>? ratings);
|
||||||
|
void Remove(IEnumerable<ExternalRecommendation>? recommendations);
|
||||||
|
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId, int limit = 25);
|
||||||
|
Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId, int libraryId, AppUser user);
|
||||||
|
Task LinkRecommendationsToSeries(Series series);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepository
|
||||||
|
{
|
||||||
|
private readonly DataContext _context;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
private readonly UserManager<AppUser> _userManager;
|
||||||
|
|
||||||
|
public ExternalSeriesMetadataRepository(DataContext context, IMapper mapper, UserManager<AppUser> userManager)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_mapper = mapper;
|
||||||
|
_userManager = userManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Attach(ExternalSeriesMetadata metadata)
|
||||||
|
{
|
||||||
|
_context.ExternalSeriesMetadata.Attach(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Attach(ExternalRating rating)
|
||||||
|
{
|
||||||
|
_context.ExternalRating.Attach(rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Attach(ExternalReview review)
|
||||||
|
{
|
||||||
|
_context.ExternalReview.Attach(review);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(IEnumerable<ExternalReview>? reviews)
|
||||||
|
{
|
||||||
|
if (reviews == null) return;
|
||||||
|
_context.ExternalReview.RemoveRange(reviews);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(IEnumerable<ExternalRating> ratings)
|
||||||
|
{
|
||||||
|
if (ratings == null) return;
|
||||||
|
_context.ExternalRating.RemoveRange(ratings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(IEnumerable<ExternalRecommendation> recommendations)
|
||||||
|
{
|
||||||
|
if (recommendations == null) return;
|
||||||
|
_context.ExternalRecommendation.RemoveRange(recommendations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the ExternalSeriesMetadata entity for the given Series including all linked tables
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId, int limit = 25)
|
||||||
|
{
|
||||||
|
return _context.ExternalSeriesMetadata
|
||||||
|
.Where(s => s.SeriesId == seriesId)
|
||||||
|
.Include(s => s.ExternalReviews.Take(25))
|
||||||
|
.Include(s => s.ExternalRatings.Take(25))
|
||||||
|
.Include(s => s.ExternalRecommendations.Take(25))
|
||||||
|
.AsSplitQuery()
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId, int libraryId, AppUser user)
|
||||||
|
{
|
||||||
|
var canSeeExternalSeries = user is { AgeRestriction: AgeRating.NotApplicable } &&
|
||||||
|
await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||||
|
|
||||||
|
var allowedLibraries = await _context.Library
|
||||||
|
.Where(library => library.AppUsers.Any(x => x.Id == user.Id))
|
||||||
|
.Select(l => l.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var userRating = await _context.AppUser.GetUserAgeRestriction(user.Id);
|
||||||
|
|
||||||
|
var seriesDetailDto = await _context.ExternalSeriesMetadata
|
||||||
|
.Where(m => m.SeriesId == seriesId)
|
||||||
|
.Include(m => m.ExternalRatings)
|
||||||
|
.Include(m => m.ExternalReviews)
|
||||||
|
.Include(m => m.ExternalRecommendations)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (seriesDetailDto == null)
|
||||||
|
{
|
||||||
|
return null; // or handle the case when seriesDetailDto is not found
|
||||||
|
}
|
||||||
|
|
||||||
|
var externalSeriesRecommendations = new List<ExternalSeriesDto>();
|
||||||
|
if (!canSeeExternalSeries)
|
||||||
|
{
|
||||||
|
externalSeriesRecommendations = seriesDetailDto.ExternalRecommendations
|
||||||
|
.Where(r => r.SeriesId is null or 0)
|
||||||
|
.Select(r => _mapper.Map<ExternalSeriesDto>(r))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var ownedSeriesRecommendations = await _context.ExternalRecommendation
|
||||||
|
.Where(r => r.SeriesId > 0 && allowedLibraries.Contains(r.Series.LibraryId))
|
||||||
|
.Join(_context.Series, r => r.SeriesId, s => s.Id, (recommendation, series) => series)
|
||||||
|
.RestrictAgainstAgeRestriction(userRating)
|
||||||
|
.OrderBy(s => s.SortName.ToLower())
|
||||||
|
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var seriesDetailPlusDto = new SeriesDetailPlusDto()
|
||||||
|
{
|
||||||
|
Ratings = seriesDetailDto.ExternalRatings.Select(r => _mapper.Map<RatingDto>(r)),
|
||||||
|
Reviews = seriesDetailDto.ExternalReviews.OrderByDescending(r => r.Score).Select(r => _mapper.Map<UserReviewDto>(r)),
|
||||||
|
Recommendations = new RecommendationDto()
|
||||||
|
{
|
||||||
|
ExternalSeries = externalSeriesRecommendations,
|
||||||
|
OwnedSeries = ownedSeriesRecommendations
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return seriesDetailPlusDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches Recommendations without a SeriesId on record and attempts to link based on Series Name/Localized Name
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="series"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task LinkRecommendationsToSeries(Series series)
|
||||||
|
{
|
||||||
|
var recMatches = await _context.ExternalRecommendation
|
||||||
|
.Where(r => r.SeriesId == null || r.SeriesId == 0)
|
||||||
|
.Where(r => EF.Functions.Like(r.Name, series.Name) ||
|
||||||
|
EF.Functions.Like(r.Name, series.LocalizedName))
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var rec in recMatches)
|
||||||
|
{
|
||||||
|
rec.SeriesId = series.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
@ -47,7 +47,11 @@ public enum SeriesIncludes
|
|||||||
Metadata = 4,
|
Metadata = 4,
|
||||||
Related = 8,
|
Related = 8,
|
||||||
Library = 16,
|
Library = 16,
|
||||||
Chapters = 32
|
Chapters = 32,
|
||||||
|
ExternalReviews = 64,
|
||||||
|
ExternalRatings = 128,
|
||||||
|
ExternalRecommendations = 256,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -133,6 +137,8 @@ public interface ISeriesRepository
|
|||||||
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
|
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
|
||||||
int userId, SeriesIncludes includes = SeriesIncludes.None);
|
int userId, SeriesIncludes includes = SeriesIncludes.None);
|
||||||
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
|
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
|
||||||
|
public Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId,
|
||||||
|
MangaFormat format);
|
||||||
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
|
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
|
||||||
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
|
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
|
||||||
Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
|
Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
|
||||||
@ -148,6 +154,7 @@ public interface ISeriesRepository
|
|||||||
Task RemoveFromOnDeck(int seriesId, int userId);
|
Task RemoveFromOnDeck(int seriesId, int userId);
|
||||||
Task ClearOnDeckRemoval(int seriesId, int userId);
|
Task ClearOnDeckRemoval(int seriesId, int userId);
|
||||||
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
|
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SeriesRepository : ISeriesRepository
|
public class SeriesRepository : ISeriesRepository
|
||||||
@ -176,6 +183,11 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
_context.Series.Attach(series);
|
_context.Series.Attach(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Attach(ExternalSeriesMetadata metadata)
|
||||||
|
{
|
||||||
|
_context.ExternalSeriesMetadata.Attach(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
public void Update(Series series)
|
public void Update(Series series)
|
||||||
{
|
{
|
||||||
_context.Entry(series).State = EntityState.Modified;
|
_context.Entry(series).State = EntityState.Modified;
|
||||||
@ -669,7 +681,6 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
|
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
|
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
|
||||||
{
|
{
|
||||||
var userProgress = await _context.AppUserProgresses
|
var userProgress = await _context.AppUserProgresses
|
||||||
@ -1124,6 +1135,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
|
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
|
||||||
FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value),
|
FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value),
|
||||||
FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId),
|
FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId),
|
||||||
|
FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value),
|
||||||
_ => throw new ArgumentOutOfRangeException()
|
_ => throw new ArgumentOutOfRangeException()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1231,8 +1243,10 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.Where(library => library.AppUsers.Any(x => x.Id == userId))
|
.Where(library => library.AppUsers.Any(x => x.Id == userId))
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.Select(l => l.Id);
|
.Select(l => l.Id);
|
||||||
|
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||||
|
|
||||||
return await _context.Series
|
return await _context.Series
|
||||||
|
.RestrictAgainstAgeRestriction(userRating)
|
||||||
.Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
|
.Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
|
||||||
.OrderBy(s => s.SortName.ToLower())
|
.OrderBy(s => s.SortName.ToLower())
|
||||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||||
@ -1571,6 +1585,27 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId,
|
||||||
|
MangaFormat format)
|
||||||
|
{
|
||||||
|
var normalizedSeries = seriesName.ToNormalized();
|
||||||
|
var normalizedLocalized = localizedName.ToNormalized();
|
||||||
|
return await _context.Series
|
||||||
|
.Where(s => s.LibraryId == libraryId)
|
||||||
|
.Where(s => s.Format == format && format != MangaFormat.Unknown)
|
||||||
|
.Where(s =>
|
||||||
|
s.NormalizedName.Equals(normalizedSeries)
|
||||||
|
|| s.NormalizedName.Equals(normalizedLocalized)
|
||||||
|
|
||||||
|
|| s.NormalizedLocalizedName.Equals(normalizedSeries)
|
||||||
|
|| (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized))
|
||||||
|
|
||||||
|
|| (s.OriginalName != null && s.OriginalName.Equals(seriesName))
|
||||||
|
)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes series that are not in the seenSeries list. Does not commit.
|
/// Removes series that are not in the seenSeries list. Does not commit.
|
||||||
@ -1863,6 +1898,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
SeriesDto? result = null;
|
SeriesDto? result = null;
|
||||||
if (!string.IsNullOrEmpty(aniListUrl) || !string.IsNullOrEmpty(malUrl))
|
if (!string.IsNullOrEmpty(aniListUrl) || !string.IsNullOrEmpty(malUrl))
|
||||||
{
|
{
|
||||||
|
// TODO: I can likely work AniList and MalIds from ExternalSeriesMetadata in here
|
||||||
result = await _context.Series
|
result = await _context.Series
|
||||||
.RestrictAgainstAgeRestriction(userRating)
|
.RestrictAgainstAgeRestriction(userRating)
|
||||||
.Where(s => !string.IsNullOrEmpty(s.Metadata.WebLinks))
|
.Where(s => !string.IsNullOrEmpty(s.Metadata.WebLinks))
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.DTOs.SeriesDetail;
|
||||||
using API.DTOs.Settings;
|
using API.DTOs.Settings;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Entities.Metadata;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@ -16,6 +18,7 @@ public interface ISettingsRepository
|
|||||||
Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
|
Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
|
||||||
Task<IEnumerable<ServerSetting>> GetSettingsAsync();
|
Task<IEnumerable<ServerSetting>> GetSettingsAsync();
|
||||||
void Remove(ServerSetting setting);
|
void Remove(ServerSetting setting);
|
||||||
|
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
|
||||||
}
|
}
|
||||||
public class SettingsRepository : ISettingsRepository
|
public class SettingsRepository : ISettingsRepository
|
||||||
{
|
{
|
||||||
@ -38,6 +41,13 @@ public class SettingsRepository : ISettingsRepository
|
|||||||
_context.Remove(setting);
|
_context.Remove(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId)
|
||||||
|
{
|
||||||
|
return await _context.ExternalSeriesMetadata
|
||||||
|
.Where(s => s.SeriesId == seriesId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ServerSettingDto> GetSettingsDtoAsync()
|
public async Task<ServerSettingDto> GetSettingsDtoAsync()
|
||||||
{
|
{
|
||||||
var settings = await _context.ServerSetting
|
var settings = await _context.ServerSetting
|
||||||
|
@ -208,8 +208,9 @@ public static class Seed
|
|||||||
{
|
{
|
||||||
new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
|
new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
|
||||||
new() {Key = ServerSettingKey.TaskScan, Value = "daily"},
|
new() {Key = ServerSettingKey.TaskScan, Value = "daily"},
|
||||||
new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"},
|
|
||||||
new() {Key = ServerSettingKey.TaskBackup, Value = "daily"},
|
new() {Key = ServerSettingKey.TaskBackup, Value = "daily"},
|
||||||
|
new() {Key = ServerSettingKey.TaskCleanup, Value = "daily"},
|
||||||
|
new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory)
|
Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory)
|
||||||
|
@ -30,6 +30,7 @@ public interface IUnitOfWork
|
|||||||
IUserTableOfContentRepository UserTableOfContentRepository { get; }
|
IUserTableOfContentRepository UserTableOfContentRepository { get; }
|
||||||
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
|
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
|
||||||
IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
|
IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
|
||||||
|
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
|
||||||
bool Commit();
|
bool Commit();
|
||||||
Task<bool> CommitAsync();
|
Task<bool> CommitAsync();
|
||||||
bool HasChanges();
|
bool HasChanges();
|
||||||
@ -72,6 +73,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper);
|
public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper);
|
||||||
public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper);
|
public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper);
|
||||||
public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper);
|
public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper);
|
||||||
|
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository => new ExternalSeriesMetadataRepository(_context, _mapper, _userManager);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Commits changes to the DB. Completes the open transaction.
|
/// Commits changes to the DB. Completes the open transaction.
|
||||||
|
@ -182,5 +182,9 @@ public enum ServerSettingKey
|
|||||||
[Description("EmailCustomizedTemplates")]
|
[Description("EmailCustomizedTemplates")]
|
||||||
EmailCustomizedTemplates = 36,
|
EmailCustomizedTemplates = 36,
|
||||||
#endregion
|
#endregion
|
||||||
|
/// <summary>
|
||||||
|
/// When the cleanup task should run - Critical to keeping Kavita working
|
||||||
|
/// </summary>
|
||||||
|
[Description("TaskCleanup")]
|
||||||
|
TaskCleanup = 37
|
||||||
}
|
}
|
||||||
|
17
API/Entities/Metadata/ExternalRating.cs
Normal file
17
API/Entities/Metadata/ExternalRating.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Services.Plus;
|
||||||
|
|
||||||
|
namespace API.Entities.Metadata;
|
||||||
|
|
||||||
|
public class ExternalRating
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int AverageScore { get; set; }
|
||||||
|
public int FavoriteCount { get; set; }
|
||||||
|
public ScrobbleProvider Provider { get; set; }
|
||||||
|
public string? ProviderUrl { get; set; }
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
|
||||||
|
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
|
||||||
|
}
|
26
API/Entities/Metadata/ExternalRecommendation.cs
Normal file
26
API/Entities/Metadata/ExternalRecommendation.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Services.Plus;
|
||||||
|
|
||||||
|
namespace API.Entities.Metadata;
|
||||||
|
|
||||||
|
public class ExternalRecommendation
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required string CoverUrl { get; set; }
|
||||||
|
public required string Url { get; set; }
|
||||||
|
public string? Summary { get; set; }
|
||||||
|
public int? AniListId { get; set; }
|
||||||
|
public long? MalId { get; set; }
|
||||||
|
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When null, represents an external series. When set, it is a Series
|
||||||
|
/// </summary>
|
||||||
|
public int? SeriesId { get; set; }
|
||||||
|
public virtual Series Series { get; set; }
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
|
||||||
|
}
|
43
API/Entities/Metadata/ExternalReview.cs
Normal file
43
API/Entities/Metadata/ExternalReview.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Services.Plus;
|
||||||
|
|
||||||
|
namespace API.Entities.Metadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an Externally supplied Review for a given Series
|
||||||
|
/// </summary>
|
||||||
|
public class ExternalReview
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Tagline { get; set; }
|
||||||
|
public required string Body { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Pure text version of the body
|
||||||
|
/// </summary>
|
||||||
|
public required string BodyJustText { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Raw from the provider. Usually Markdown
|
||||||
|
/// </summary>
|
||||||
|
public string RawBody { get; set; }
|
||||||
|
public required ScrobbleProvider Provider { get; set; }
|
||||||
|
public string SiteUrl { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Reviewer's username
|
||||||
|
/// </summary>
|
||||||
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An Optional Rating coming from the Review
|
||||||
|
/// </summary>
|
||||||
|
public int Rating { get; set; } = 0;
|
||||||
|
/// <summary>
|
||||||
|
/// The media's overall Score
|
||||||
|
/// </summary>
|
||||||
|
public int Score { get; set; }
|
||||||
|
public int TotalVotes { get; set; }
|
||||||
|
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
|
||||||
|
}
|
36
API/Entities/Metadata/ExternalSeriesMetadata.cs
Normal file
36
API/Entities/Metadata/ExternalSeriesMetadata.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace API.Entities.Metadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// External Metadata from Kavita+ for a Series
|
||||||
|
/// </summary>
|
||||||
|
public class ExternalSeriesMetadata
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// External Reviews for the Series. Managed by Kavita for Kavita+ users
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<ExternalReview> ExternalReviews { get; set; } = null!;
|
||||||
|
public ICollection<ExternalRating> ExternalRatings { get; set; } = null!;
|
||||||
|
/// <summary>
|
||||||
|
/// External recommendations will include all recommendations and will have a seriesId if it's on this Kavita instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Cleanup Service will perform matching to tie new series with recommendations</remarks>
|
||||||
|
public ICollection<ExternalRecommendation> ExternalRecommendations { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Average External Rating. -1 means not set
|
||||||
|
/// </summary>
|
||||||
|
public int AverageExternalRating { get; set; } = 0;
|
||||||
|
|
||||||
|
public int AniListId { get; set; }
|
||||||
|
public long MalId { get; set; }
|
||||||
|
public string GoogleBooksId { get; set; }
|
||||||
|
|
||||||
|
public DateTime LastUpdatedUtc { get; set; }
|
||||||
|
|
||||||
|
public Series Series { get; set; } = null!;
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
}
|
@ -96,6 +96,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate
|
|||||||
public int AvgHoursToRead { get; set; }
|
public int AvgHoursToRead { get; set; }
|
||||||
|
|
||||||
public SeriesMetadata Metadata { get; set; } = null!;
|
public SeriesMetadata Metadata { get; set; } = null!;
|
||||||
|
public ExternalSeriesMetadata ExternalSeriesMetadata { get; set; } = null!;
|
||||||
|
|
||||||
public ICollection<AppUserRating> Ratings { get; set; } = null!;
|
public ICollection<AppUserRating> Ratings { get; set; } = null!;
|
||||||
public ICollection<AppUserProgress> Progress { get; set; } = null!;
|
public ICollection<AppUserProgress> Progress { get; set; } = null!;
|
||||||
@ -113,6 +114,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate
|
|||||||
public Library Library { get; set; } = null!;
|
public Library Library { get; set; } = null!;
|
||||||
public int LibraryId { get; set; }
|
public int LibraryId { get; set; }
|
||||||
|
|
||||||
|
|
||||||
public void UpdateLastFolderScanned()
|
public void UpdateLastFolderScanned()
|
||||||
{
|
{
|
||||||
LastFolderScanned = DateTime.Now;
|
LastFolderScanned = DateTime.Now;
|
||||||
|
@ -72,8 +72,6 @@ public static class ApplicationServiceExtensions
|
|||||||
|
|
||||||
services.AddScoped<IScrobblingService, ScrobblingService>();
|
services.AddScoped<IScrobblingService, ScrobblingService>();
|
||||||
services.AddScoped<ILicenseService, LicenseService>();
|
services.AddScoped<ILicenseService, LicenseService>();
|
||||||
services.AddScoped<IReviewService, ReviewService>();
|
|
||||||
services.AddScoped<IRatingService, RatingService>();
|
|
||||||
services.AddScoped<IExternalMetadataService, ExternalMetadataService>();
|
services.AddScoped<IExternalMetadataService, ExternalMetadataService>();
|
||||||
|
|
||||||
services.AddSqLite();
|
services.AddSqLite();
|
||||||
@ -85,13 +83,10 @@ public static class ApplicationServiceExtensions
|
|||||||
options.UseInMemory(EasyCacheProfiles.License);
|
options.UseInMemory(EasyCacheProfiles.License);
|
||||||
options.UseInMemory(EasyCacheProfiles.Library);
|
options.UseInMemory(EasyCacheProfiles.Library);
|
||||||
options.UseInMemory(EasyCacheProfiles.RevokedJwt);
|
options.UseInMemory(EasyCacheProfiles.RevokedJwt);
|
||||||
options.UseInMemory(EasyCacheProfiles.Filter);
|
|
||||||
|
|
||||||
// KavitaPlus stuff
|
// KavitaPlus stuff
|
||||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusReviews);
|
|
||||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusRecommendations);
|
|
||||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusRatings);
|
|
||||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries);
|
options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries);
|
||||||
|
options.UseInMemory(EasyCacheProfiles.KavitaPlusSeriesDetail);
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddMemoryCache(options =>
|
services.AddMemoryCache(options =>
|
||||||
|
@ -287,6 +287,60 @@ public static class SeriesFilter
|
|||||||
return queryable.Where(s => ids.Contains(s.Id));
|
return queryable.Where(s => ids.Contains(s.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IQueryable<Series> HasAverageRating(this IQueryable<Series> queryable, bool condition,
|
||||||
|
FilterComparison comparison, float rating)
|
||||||
|
{
|
||||||
|
if (!condition) return queryable;
|
||||||
|
|
||||||
|
var subQuery = queryable
|
||||||
|
.Where(s => s.ExternalSeriesMetadata != null)
|
||||||
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
Series = s,
|
||||||
|
AverageRating = s.ExternalSeriesMetadata.AverageExternalRating
|
||||||
|
})
|
||||||
|
.AsEnumerable();
|
||||||
|
|
||||||
|
switch (comparison)
|
||||||
|
{
|
||||||
|
case FilterComparison.Equal:
|
||||||
|
subQuery = subQuery.Where(s => Math.Abs(s.AverageRating - rating) < FloatingPointTolerance);
|
||||||
|
break;
|
||||||
|
case FilterComparison.GreaterThan:
|
||||||
|
subQuery = subQuery.Where(s => s.AverageRating > rating);
|
||||||
|
break;
|
||||||
|
case FilterComparison.GreaterThanEqual:
|
||||||
|
subQuery = subQuery.Where(s => s.AverageRating >= rating);
|
||||||
|
break;
|
||||||
|
case FilterComparison.LessThan:
|
||||||
|
subQuery = subQuery.Where(s => s.AverageRating < rating);
|
||||||
|
break;
|
||||||
|
case FilterComparison.LessThanEqual:
|
||||||
|
subQuery = subQuery.Where(s => s.AverageRating <= rating);
|
||||||
|
break;
|
||||||
|
case FilterComparison.NotEqual:
|
||||||
|
subQuery = subQuery.Where(s => Math.Abs(s.AverageRating - rating) > FloatingPointTolerance);
|
||||||
|
break;
|
||||||
|
case FilterComparison.Matches:
|
||||||
|
case FilterComparison.Contains:
|
||||||
|
case FilterComparison.NotContains:
|
||||||
|
case FilterComparison.BeginsWith:
|
||||||
|
case FilterComparison.EndsWith:
|
||||||
|
case FilterComparison.IsBefore:
|
||||||
|
case FilterComparison.IsAfter:
|
||||||
|
case FilterComparison.IsInLast:
|
||||||
|
case FilterComparison.IsNotInLast:
|
||||||
|
case FilterComparison.MustContains:
|
||||||
|
throw new KavitaException($"{comparison} not applicable for Series.AverageRating");
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids = subQuery.Select(s => s.Series.Id).ToList();
|
||||||
|
return queryable.Where(s => ids.Contains(s.Id));
|
||||||
|
}
|
||||||
|
|
||||||
public static IQueryable<Series> HasReadingDate(this IQueryable<Series> queryable, bool condition,
|
public static IQueryable<Series> HasReadingDate(this IQueryable<Series> queryable, bool condition,
|
||||||
FilterComparison comparison, DateTime? date, int userId)
|
FilterComparison comparison, DateTime? date, int userId)
|
||||||
{
|
{
|
||||||
|
@ -36,7 +36,6 @@ public static class IncludesExtensions
|
|||||||
.Include(c => c.Files);
|
.Include(c => c.Files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return queryable.AsSplitQuery();
|
return queryable.AsSplitQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +66,27 @@ public static class IncludesExtensions
|
|||||||
.Include(s => s.RelationOf);
|
.Include(s => s.RelationOf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeFlags.HasFlag(SeriesIncludes.ExternalReviews))
|
||||||
|
{
|
||||||
|
query = query
|
||||||
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
|
.ThenInclude(s => s.ExternalReviews);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeFlags.HasFlag(SeriesIncludes.ExternalRatings))
|
||||||
|
{
|
||||||
|
query = query
|
||||||
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
|
.ThenInclude(s => s.ExternalRatings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeFlags.HasFlag(SeriesIncludes.ExternalRecommendations))
|
||||||
|
{
|
||||||
|
query = query
|
||||||
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
|
.ThenInclude(s => s.ExternalRecommendations);
|
||||||
|
}
|
||||||
|
|
||||||
if (includeFlags.HasFlag(SeriesIncludes.Metadata))
|
if (includeFlags.HasFlag(SeriesIncludes.Metadata))
|
||||||
{
|
{
|
||||||
query = query.Include(s => s.Metadata)
|
query = query.Include(s => s.Metadata)
|
||||||
|
@ -12,6 +12,7 @@ using API.DTOs.MediaErrors;
|
|||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.DTOs.ReadingLists;
|
using API.DTOs.ReadingLists;
|
||||||
|
using API.DTOs.Recommendation;
|
||||||
using API.DTOs.Scrobbling;
|
using API.DTOs.Scrobbling;
|
||||||
using API.DTOs.Search;
|
using API.DTOs.Search;
|
||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
@ -24,6 +25,7 @@ using API.Entities.Metadata;
|
|||||||
using API.Entities.Scrobble;
|
using API.Entities.Scrobble;
|
||||||
using API.Extensions.QueryExtensions.Filtering;
|
using API.Extensions.QueryExtensions.Filtering;
|
||||||
using API.Helpers.Converters;
|
using API.Helpers.Converters;
|
||||||
|
using API.Services;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using CollectionTag = API.Entities.CollectionTag;
|
using CollectionTag = API.Entities.CollectionTag;
|
||||||
using MediaError = API.Entities.MediaError;
|
using MediaError = API.Entities.MediaError;
|
||||||
@ -245,5 +247,18 @@ public class AutoMapperProfiles : Profile
|
|||||||
CreateMap<AppUserDashboardStream, AppUserDashboardStream>();
|
CreateMap<AppUserDashboardStream, AppUserDashboardStream>();
|
||||||
CreateMap<AppUserSideNavStream, AppUserSideNavStream>();
|
CreateMap<AppUserSideNavStream, AppUserSideNavStream>();
|
||||||
|
|
||||||
|
CreateMap<ExternalRating, RatingDto>();
|
||||||
|
CreateMap<RatingDto, ExternalRating>();
|
||||||
|
CreateMap<ExternalReview, UserReviewDto>()
|
||||||
|
.ForMember(dest => dest.IsExternal,
|
||||||
|
opt =>
|
||||||
|
opt.MapFrom(src => true));
|
||||||
|
|
||||||
|
CreateMap<UserReviewDto, ExternalReview>()
|
||||||
|
.ForMember(dest => dest.BodyJustText,
|
||||||
|
opt =>
|
||||||
|
opt.MapFrom(src => ReviewService.GetCharacters(src.Body)));
|
||||||
|
|
||||||
|
CreateMap<ExternalRecommendation, ExternalSeriesDto>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Scrobbling;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
|
|
||||||
|
@ -27,7 +27,8 @@ public class SeriesBuilder : IEntityBuilder<Series>
|
|||||||
NormalizedName = name.ToNormalized(),
|
NormalizedName = name.ToNormalized(),
|
||||||
NormalizedLocalizedName = name.ToNormalized(),
|
NormalizedLocalizedName = name.ToNormalized(),
|
||||||
Metadata = new SeriesMetadataBuilder().Build(),
|
Metadata = new SeriesMetadataBuilder().Build(),
|
||||||
Volumes = new List<Volume>()
|
Volumes = new List<Volume>(),
|
||||||
|
ExternalSeriesMetadata = new ExternalSeriesMetadata()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,16 +14,13 @@ public static class CronConverter
|
|||||||
};
|
};
|
||||||
public static string ConvertToCronNotation(string source)
|
public static string ConvertToCronNotation(string source)
|
||||||
{
|
{
|
||||||
var destination = string.Empty;
|
return source.ToLower() switch
|
||||||
destination = source.ToLower() switch
|
|
||||||
{
|
{
|
||||||
"daily" => Cron.Daily(),
|
"daily" => Cron.Daily(),
|
||||||
"weekly" => Cron.Weekly(),
|
"weekly" => Cron.Weekly(),
|
||||||
"disabled" => Cron.Never(),
|
"disabled" => Cron.Never(),
|
||||||
"" => Cron.Never(),
|
"" => Cron.Never(),
|
||||||
_ => destination
|
_ => source
|
||||||
};
|
};
|
||||||
|
|
||||||
return destination;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,6 +77,7 @@ public static class FilterFieldValueConverter
|
|||||||
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
|
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.ReadTime => int.Parse(value),
|
FilterField.ReadTime => int.Parse(value),
|
||||||
|
FilterField.AverageRating => float.Parse(value),
|
||||||
_ => throw new ArgumentException("Invalid field type")
|
_ => throw new ArgumentException("Invalid field type")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -23,12 +23,15 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
|
|||||||
case ServerSettingKey.TaskScan:
|
case ServerSettingKey.TaskScan:
|
||||||
destination.TaskScan = row.Value;
|
destination.TaskScan = row.Value;
|
||||||
break;
|
break;
|
||||||
case ServerSettingKey.LoggingLevel:
|
|
||||||
destination.LoggingLevel = row.Value;
|
|
||||||
break;
|
|
||||||
case ServerSettingKey.TaskBackup:
|
case ServerSettingKey.TaskBackup:
|
||||||
destination.TaskBackup = row.Value;
|
destination.TaskBackup = row.Value;
|
||||||
break;
|
break;
|
||||||
|
case ServerSettingKey.TaskCleanup:
|
||||||
|
destination.TaskCleanup = row.Value;
|
||||||
|
break;
|
||||||
|
case ServerSettingKey.LoggingLevel:
|
||||||
|
destination.LoggingLevel = row.Value;
|
||||||
|
break;
|
||||||
case ServerSettingKey.Port:
|
case ServerSettingKey.Port:
|
||||||
destination.Port = int.Parse(row.Value);
|
destination.Port = int.Parse(row.Value);
|
||||||
break;
|
break;
|
||||||
|
@ -65,8 +65,8 @@ public static class LogLevelOptions
|
|||||||
e.Properties["SourceContext"].ToString().Replace("\"", string.Empty) ==
|
e.Properties["SourceContext"].ToString().Replace("\"", string.Empty) ==
|
||||||
"Serilog.AspNetCore.RequestLoggingMiddleware";
|
"Serilog.AspNetCore.RequestLoggingMiddleware";
|
||||||
|
|
||||||
// If Minimum log level is Information, swallow all Request Logging messages
|
// If Minimum log level is Warning, swallow all Request Logging messages
|
||||||
if (isRequestLoggingMiddleware && LogLevelSwitch.MinimumLevel >= LogEventLevel.Information)
|
if (isRequestLoggingMiddleware && LogLevelSwitch.MinimumLevel > LogEventLevel.Information)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,10 @@ using API.DTOs.Scrobbling;
|
|||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Entities.Metadata;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
|
using AutoMapper;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Kavita.Common.EnvironmentInfo;
|
using Kavita.Common.EnvironmentInfo;
|
||||||
@ -39,6 +41,8 @@ internal class SeriesDetailPlusAPIDto
|
|||||||
public IEnumerable<MediaRecommendationDto> Recommendations { get; set; }
|
public IEnumerable<MediaRecommendationDto> Recommendations { get; set; }
|
||||||
public IEnumerable<UserReviewDto> Reviews { get; set; }
|
public IEnumerable<UserReviewDto> Reviews { get; set; }
|
||||||
public IEnumerable<RatingDto> Ratings { get; set; }
|
public IEnumerable<RatingDto> Ratings { get; set; }
|
||||||
|
public int? AniListId { get; set; }
|
||||||
|
public long? MalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IExternalMetadataService
|
public interface IExternalMetadataService
|
||||||
@ -51,11 +55,14 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ILogger<ExternalMetadataService> _logger;
|
private readonly ILogger<ExternalMetadataService> _logger;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(14);
|
||||||
|
|
||||||
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger)
|
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_mapper = mapper;
|
||||||
|
|
||||||
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
||||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||||
@ -76,8 +83,12 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
throw new KavitaException("Unable to find valid information from url for External Load");
|
throw new KavitaException("Unable to find valid information from url for External Load");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is for the Series drawer. We can get this extra information during the initial SeriesDetail call so it's all coming from the DB
|
||||||
|
|
||||||
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||||
return await GetSeriesDetail(license, aniListId, malId, seriesId);
|
var details = await GetSeriesDetail(license, aniListId, malId, seriesId);
|
||||||
|
|
||||||
|
return details;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,13 +98,22 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
||||||
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters);
|
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters);
|
||||||
if (series == null || series.Library.Type == LibraryType.Comic) return new SeriesDetailPlusDto();
|
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);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||||
|
if (user == null) return new SeriesDetailPlusDto();
|
||||||
|
|
||||||
|
// Let's try to get SeriesDetailPlusDto from the local DB.
|
||||||
|
var externalSeriesMetadata = await GetExternalSeriesMetadataForSeries(seriesId, series);
|
||||||
|
var needsRefresh = externalSeriesMetadata.LastUpdatedUtc <= DateTime.UtcNow.Subtract(_externalSeriesMetadataCache);
|
||||||
|
|
||||||
|
if (!needsRefresh)
|
||||||
|
{
|
||||||
|
// Convert into DTOs and return
|
||||||
|
return await SerializeExternalSeriesDetail(seriesId, externalSeriesMetadata, user, series);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||||
var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/series-detail")
|
var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/series-detail")
|
||||||
.WithHeader("Accept", "application/json")
|
.WithHeader("Accept", "application/json")
|
||||||
.WithHeader("User-Agent", "Kavita")
|
.WithHeader("User-Agent", "Kavita")
|
||||||
@ -106,13 +126,49 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
.ReceiveJson<SeriesDetailPlusAPIDto>();
|
.ReceiveJson<SeriesDetailPlusAPIDto>();
|
||||||
|
|
||||||
|
|
||||||
var recs = await ProcessRecommendations(series, user!, result.Recommendations);
|
// Clear out existing results
|
||||||
return new SeriesDetailPlusDto()
|
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews);
|
||||||
|
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings);
|
||||||
|
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations);
|
||||||
|
|
||||||
|
externalSeriesMetadata.ExternalReviews = result.Reviews.Select(r =>
|
||||||
|
{
|
||||||
|
var review = _mapper.Map<ExternalReview>(r);
|
||||||
|
review.SeriesId = externalSeriesMetadata.SeriesId;
|
||||||
|
return review;
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
externalSeriesMetadata.ExternalRatings = result.Ratings.Select(r =>
|
||||||
|
{
|
||||||
|
var rating = _mapper.Map<ExternalRating>(r);
|
||||||
|
rating.SeriesId = externalSeriesMetadata.SeriesId;
|
||||||
|
return rating;
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
|
||||||
|
// Recommendations
|
||||||
|
|
||||||
|
externalSeriesMetadata.ExternalRecommendations ??= new List<ExternalRecommendation>();
|
||||||
|
var recs = await ProcessRecommendations(series, user!, result.Recommendations, externalSeriesMetadata);
|
||||||
|
|
||||||
|
externalSeriesMetadata.LastUpdatedUtc = DateTime.UtcNow;
|
||||||
|
externalSeriesMetadata.AverageExternalRating = (int) externalSeriesMetadata.ExternalRatings
|
||||||
|
.Where(r => r.AverageScore > 0)
|
||||||
|
.Average(r => r.AverageScore);
|
||||||
|
|
||||||
|
if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value;
|
||||||
|
if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value;
|
||||||
|
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
|
var ret = new SeriesDetailPlusDto()
|
||||||
{
|
{
|
||||||
Recommendations = recs,
|
Recommendations = recs,
|
||||||
Ratings = result.Ratings,
|
Ratings = result.Ratings,
|
||||||
Reviews = result.Reviews
|
Reviews = result.Reviews
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
catch (FlurlHttpException ex)
|
catch (FlurlHttpException ex)
|
||||||
{
|
{
|
||||||
@ -129,7 +185,63 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<RecommendationDto> ProcessRecommendations(Series series, AppUser user, IEnumerable<MediaRecommendationDto> recs)
|
private async Task<SeriesDetailPlusDto?> SerializeExternalSeriesDetail(int seriesId, ExternalSeriesMetadata externalSeriesMetadata,
|
||||||
|
AppUser user, Series series)
|
||||||
|
{
|
||||||
|
var seriesIdsOnServer = externalSeriesMetadata.ExternalRecommendations
|
||||||
|
.Where(r => r.SeriesId is > 0)
|
||||||
|
.Select(s => (int) s.SeriesId!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var ownedSeries = (await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(seriesIdsOnServer, user.Id))
|
||||||
|
.ToList();
|
||||||
|
var canSeeExternalSeries = user is {AgeRestriction: AgeRating.NotApplicable} &&
|
||||||
|
await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||||
|
var externalSeries = new List<ExternalSeriesDto>();
|
||||||
|
if (canSeeExternalSeries)
|
||||||
|
{
|
||||||
|
externalSeries = externalSeriesMetadata.ExternalRecommendations
|
||||||
|
.Where(r => r.SeriesId is null or 0)
|
||||||
|
.Select(r => _mapper.Map<ExternalSeriesDto>(r))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesDetailPlusDto(seriesId, series.LibraryId, user);
|
||||||
|
|
||||||
|
return new SeriesDetailPlusDto()
|
||||||
|
{
|
||||||
|
Ratings = externalSeriesMetadata.ExternalRatings.Select(r => _mapper.Map<RatingDto>(r)),
|
||||||
|
Reviews = externalSeriesMetadata.ExternalReviews.OrderByDescending(r => r.Score).Select(r =>
|
||||||
|
{
|
||||||
|
var review = _mapper.Map<UserReviewDto>(r);
|
||||||
|
review.SeriesId = seriesId;
|
||||||
|
review.LibraryId = series.LibraryId;
|
||||||
|
review.IsExternal = true;
|
||||||
|
return review;
|
||||||
|
}),
|
||||||
|
Recommendations = new RecommendationDto()
|
||||||
|
{
|
||||||
|
ExternalSeries = externalSeries,
|
||||||
|
OwnedSeries = ownedSeries
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ExternalSeriesMetadata> GetExternalSeriesMetadataForSeries(int seriesId, Series series)
|
||||||
|
{
|
||||||
|
var externalSeriesMetadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
|
||||||
|
if (externalSeriesMetadata == null)
|
||||||
|
{
|
||||||
|
externalSeriesMetadata = new ExternalSeriesMetadata();
|
||||||
|
series.ExternalSeriesMetadata = externalSeriesMetadata;
|
||||||
|
externalSeriesMetadata.SeriesId = series.Id;
|
||||||
|
_unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
return externalSeriesMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RecommendationDto> ProcessRecommendations(Series series, AppUser user, IEnumerable<MediaRecommendationDto> recs, ExternalSeriesMetadata externalSeriesMetadata)
|
||||||
{
|
{
|
||||||
var recDto = new RecommendationDto()
|
var recDto = new RecommendationDto()
|
||||||
{
|
{
|
||||||
@ -139,6 +251,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
|
|
||||||
var canSeeExternalSeries = user is {AgeRestriction: AgeRating.NotApplicable} &&
|
var canSeeExternalSeries = user is {AgeRestriction: AgeRating.NotApplicable} &&
|
||||||
await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||||
|
// NOTE: This can result in a series being recommended that shares the same name but different format
|
||||||
foreach (var rec in recs)
|
foreach (var rec in recs)
|
||||||
{
|
{
|
||||||
// Find the series based on name and type and that the user has access too
|
// Find the series based on name and type and that the user has access too
|
||||||
@ -149,6 +262,17 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
if (seriesForRec != null)
|
if (seriesForRec != null)
|
||||||
{
|
{
|
||||||
recDto.OwnedSeries.Add(seriesForRec);
|
recDto.OwnedSeries.Add(seriesForRec);
|
||||||
|
externalSeriesMetadata.ExternalRecommendations.Add(new ExternalRecommendation()
|
||||||
|
{
|
||||||
|
SeriesId = seriesForRec.Id,
|
||||||
|
AniListId = rec.AniListId,
|
||||||
|
MalId = rec.MalId,
|
||||||
|
Name = seriesForRec.Name,
|
||||||
|
Url = rec.SiteUrl,
|
||||||
|
CoverUrl = rec.CoverUrl,
|
||||||
|
Summary = rec.Summary,
|
||||||
|
Provider = rec.Provider
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +288,17 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
AniListId = rec.AniListId,
|
AniListId = rec.AniListId,
|
||||||
MalId = rec.MalId
|
MalId = rec.MalId
|
||||||
});
|
});
|
||||||
|
externalSeriesMetadata.ExternalRecommendations.Add(new ExternalRecommendation()
|
||||||
|
{
|
||||||
|
SeriesId = null,
|
||||||
|
AniListId = rec.AniListId,
|
||||||
|
MalId = rec.MalId,
|
||||||
|
Name = rec.Name,
|
||||||
|
Url = rec.SiteUrl,
|
||||||
|
CoverUrl = rec.CoverUrl,
|
||||||
|
Summary = rec.Summary,
|
||||||
|
Provider = rec.Provider
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, recDto.OwnedSeries);
|
await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, recDto.OwnedSeries);
|
||||||
@ -184,9 +319,10 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
SeriesName = string.Empty,
|
SeriesName = string.Empty,
|
||||||
LocalizedSeriesName = string.Empty
|
LocalizedSeriesName = string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
if (seriesId is > 0)
|
if (seriesId is > 0)
|
||||||
{
|
{
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId.Value, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId.Value, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalReviews);
|
||||||
if (series != null)
|
if (series != null)
|
||||||
{
|
{
|
||||||
if (payload.AniListId <= 0)
|
if (payload.AniListId <= 0)
|
||||||
|
@ -1,102 +0,0 @@
|
|||||||
using System;
|
|
||||||
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;
|
|
||||||
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;
|
|
||||||
using Kavita.Common.Helpers;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace API.Services.Plus;
|
|
||||||
#nullable enable
|
|
||||||
|
|
||||||
public interface IRatingService
|
|
||||||
{
|
|
||||||
Task<IEnumerable<RatingDto>> GetRatings(int seriesId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class RatingService : IRatingService
|
|
||||||
{
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
private readonly ILogger<RatingService> _logger;
|
|
||||||
private readonly IEasyCachingProvider _cacheProvider;
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await (Configuration.KavitaPlusApiUrl + "/api/rating")
|
|
||||||
.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<IEnumerable<RatingDto>>();
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.LogError(e, "An error happened during the request to Kavita+ API");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new List<RatingDto>();
|
|
||||||
}
|
|
||||||
}
|
|
@ -20,43 +20,13 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace API.Services.Plus;
|
namespace API.Services.Plus;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
public record PlusSeriesDto
|
|
||||||
{
|
|
||||||
public int? AniListId { get; set; }
|
|
||||||
public long? MalId { get; set; }
|
|
||||||
public string? GoogleBooksId { get; set; }
|
|
||||||
public string? MangaDexId { get; set; }
|
|
||||||
public string SeriesName { get; set; }
|
|
||||||
public string? AltSeriesName { get; set; }
|
|
||||||
public MediaFormat MediaFormat { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Optional but can help with matching
|
|
||||||
/// </summary>
|
|
||||||
public int? ChapterCount { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Optional but can help with matching
|
|
||||||
/// </summary>
|
|
||||||
public int? VolumeCount { get; set; }
|
|
||||||
public int? Year { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public record MediaRecommendationDto
|
|
||||||
{
|
|
||||||
public int Rating { get; set; }
|
|
||||||
public IEnumerable<string> RecommendationNames { get; set; } = null!;
|
|
||||||
public string Name { get; set; }
|
|
||||||
public string CoverUrl { get; set; }
|
|
||||||
public string SiteUrl { get; set; }
|
|
||||||
public string? Summary { get; set; }
|
|
||||||
public int? AniListId { get; set; }
|
|
||||||
public long? MalId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IRecommendationService
|
public interface IRecommendationService
|
||||||
{
|
{
|
||||||
Task<RecommendationDto> GetRecommendationsForSeries(int userId, int seriesId);
|
//Task<RecommendationDto> GetRecommendationsForSeries(int userId, int seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public class RecommendationService : IRecommendationService
|
public class RecommendationService : IRecommendationService
|
||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
@ -1,114 +1,16 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Immutable;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using API.Constants;
|
|
||||||
using API.Data;
|
|
||||||
using API.Data.Repositories;
|
|
||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
using API.Entities;
|
|
||||||
using API.Entities.Enums;
|
|
||||||
using API.Helpers;
|
|
||||||
using API.Helpers.Builders;
|
|
||||||
using API.Services.Plus;
|
|
||||||
using EasyCaching.Core;
|
|
||||||
using Flurl.Http;
|
|
||||||
using HtmlAgilityPack;
|
using HtmlAgilityPack;
|
||||||
using Kavita.Common;
|
|
||||||
using Kavita.Common.EnvironmentInfo;
|
|
||||||
using Kavita.Common.Helpers;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
|
|
||||||
internal class MediaReviewDto
|
public static class ReviewService
|
||||||
{
|
{
|
||||||
public string Body { get; set; }
|
public static IList<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
|
||||||
public string Tagline { get; set; }
|
|
||||||
public int Rating { get; set; }
|
|
||||||
public int TotalVotes { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// The media's overall Score
|
|
||||||
/// </summary>
|
|
||||||
public int Score { get; set; }
|
|
||||||
public string SiteUrl { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// In Markdown
|
|
||||||
/// </summary>
|
|
||||||
public string RawBody { get; set; }
|
|
||||||
public string Username { get; set; }
|
|
||||||
public ScrobbleProvider Provider { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IReviewService
|
|
||||||
{
|
|
||||||
Task<IEnumerable<UserReviewDto>> GetReviewsForSeries(int userId, int seriesId);
|
|
||||||
}
|
|
||||||
|
|
||||||
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, 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;
|
IList<UserReviewDto> externalReviews;
|
||||||
var totalReviews = reviews.Count;
|
var totalReviews = reviews.Count;
|
||||||
@ -142,33 +44,7 @@ public class ReviewService : IReviewService
|
|||||||
|
|
||||||
return externalReviews;
|
return externalReviews;
|
||||||
}
|
}
|
||||||
|
public static string GetCharacters(string body)
|
||||||
private async Task<IEnumerable<UserReviewDto>> GetExternalReviews(int userId, int seriesId)
|
|
||||||
{
|
|
||||||
var series =
|
|
||||||
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
|
||||||
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Chapters | SeriesIncludes.Volumes);
|
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
|
||||||
if (user == null || series == null) return new List<UserReviewDto>();
|
|
||||||
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
|
||||||
var ret = (await GetReviews(license.Value, series)).Select(r => new UserReviewDto()
|
|
||||||
{
|
|
||||||
Body = r.Body,
|
|
||||||
Tagline = r.Tagline,
|
|
||||||
Score = r.Score,
|
|
||||||
Username = r.Username,
|
|
||||||
LibraryId = series.LibraryId,
|
|
||||||
SeriesId = series.Id,
|
|
||||||
IsExternal = true,
|
|
||||||
Provider = r.Provider,
|
|
||||||
BodyJustText = GetCharacters(r.Body),
|
|
||||||
ExternalUrl = r.SiteUrl
|
|
||||||
});
|
|
||||||
|
|
||||||
return ret.OrderByDescending(r => r.Score);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetCharacters(string body)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(body)) return body;
|
if (string.IsNullOrEmpty(body)) return body;
|
||||||
|
|
||||||
@ -204,29 +80,4 @@ public class ReviewService : IReviewService
|
|||||||
return plainText + "…";
|
return plainText + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<IEnumerable<MediaReviewDto>> GetReviews(string license, Series series)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Fetching external reviews for Series: {SeriesName}", series.Name);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await (Configuration.KavitaPlusApiUrl + "/api/review")
|
|
||||||
.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<IEnumerable<MediaReviewDto>>();
|
|
||||||
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.LogError(e, "An error happened during the request to Kavita+ API");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new List<MediaReviewDto>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,8 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Comparators;
|
using API.Comparators;
|
||||||
|
using API.Constants;
|
||||||
|
using API.Controllers;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
@ -19,6 +21,7 @@ using API.Helpers.Builders;
|
|||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
|
using EasyCaching.Core;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -29,7 +32,7 @@ namespace API.Services;
|
|||||||
public interface ISeriesService
|
public interface ISeriesService
|
||||||
{
|
{
|
||||||
Task<SeriesDetailDto> GetSeriesDetail(int seriesId, int userId);
|
Task<SeriesDetailDto> GetSeriesDetail(int seriesId, int userId);
|
||||||
Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto);
|
Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto, int userId = 0);
|
||||||
Task<bool> UpdateRating(AppUser user, UpdateSeriesRatingDto updateSeriesRatingDto);
|
Task<bool> UpdateRating(AppUser user, UpdateSeriesRatingDto updateSeriesRatingDto);
|
||||||
Task<bool> DeleteMultipleSeries(IList<int> seriesIds);
|
Task<bool> DeleteMultipleSeries(IList<int> seriesIds);
|
||||||
Task<bool> UpdateRelatedSeries(UpdateRelatedSeriesDto dto);
|
Task<bool> UpdateRelatedSeries(UpdateRelatedSeriesDto dto);
|
||||||
@ -51,6 +54,7 @@ public class SeriesService : ISeriesService
|
|||||||
private readonly ILogger<SeriesService> _logger;
|
private readonly ILogger<SeriesService> _logger;
|
||||||
private readonly IScrobblingService _scrobblingService;
|
private readonly IScrobblingService _scrobblingService;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly IEasyCachingProvider _cacheProvider;
|
||||||
|
|
||||||
private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto
|
private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto
|
||||||
{
|
{
|
||||||
@ -60,7 +64,8 @@ public class SeriesService : ISeriesService
|
|||||||
};
|
};
|
||||||
|
|
||||||
public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler,
|
public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler,
|
||||||
ILogger<SeriesService> logger, IScrobblingService scrobblingService, ILocalizationService localizationService)
|
ILogger<SeriesService> logger, IScrobblingService scrobblingService, ILocalizationService localizationService,
|
||||||
|
IEasyCachingProviderFactory cachingProviderFactory)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_eventHub = eventHub;
|
_eventHub = eventHub;
|
||||||
@ -69,6 +74,8 @@ public class SeriesService : ISeriesService
|
|||||||
_scrobblingService = scrobblingService;
|
_scrobblingService = scrobblingService;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
|
||||||
|
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusSeriesDetail);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -100,8 +107,15 @@ public class SeriesService : ISeriesService
|
|||||||
return minChapter;
|
return minChapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
|
/// <summary>
|
||||||
|
/// Updates the Series Metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="updateSeriesMetadataDto"></param>
|
||||||
|
/// <param name="userId">If 0, does not bust any cache</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto, int userId = 0)
|
||||||
{
|
{
|
||||||
|
var hasWebLinksChanged = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
|
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
|
||||||
@ -157,6 +171,8 @@ public class SeriesService : ISeriesService
|
|||||||
series.Metadata.WebLinks = string.Empty;
|
series.Metadata.WebLinks = string.Empty;
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
|
hasWebLinksChanged =
|
||||||
|
series.Metadata.WebLinks.Equals(updateSeriesMetadataDto.SeriesMetadata?.WebLinks);
|
||||||
series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks
|
series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks
|
||||||
.Split(",")
|
.Split(",")
|
||||||
.Where(s => !string.IsNullOrEmpty(s))
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
@ -299,13 +315,18 @@ public class SeriesService : ISeriesService
|
|||||||
_logger.LogError(ex, "There was an issue cleaning up DB entries. This may happen if Komf is spamming updates. Nightly cleanup will work");
|
_logger.LogError(ex, "There was an issue cleaning up DB entries. This may happen if Komf is spamming updates. Nightly cleanup will work");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasWebLinksChanged && userId > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Clearing cache as series weblinks may have changed");
|
||||||
|
await _cacheProvider.RemoveAsync(MetadataController.CacheKey + seriesId + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (updateSeriesMetadataDto.CollectionTags == null) return true;
|
if (updateSeriesMetadataDto.CollectionTags == null) return true;
|
||||||
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
|
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
|
||||||
{
|
{
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection,
|
await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection,
|
||||||
MessageFactory.SeriesAddedToCollectionEvent(tag.Id,
|
MessageFactory.SeriesAddedToCollectionEvent(tag.Id, seriesId), false);
|
||||||
updateSeriesMetadataDto.SeriesMetadata.SeriesId), false);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -141,7 +141,10 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), Cron.Weekly, RecurringJobOptions);
|
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), Cron.Weekly, RecurringJobOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, RecurringJobOptions);
|
setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskCleanup)).Value;
|
||||||
|
_logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting);
|
||||||
|
RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), CronConverter.ConvertToCronNotation(setting), RecurringJobOptions);
|
||||||
|
|
||||||
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions);
|
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions);
|
||||||
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions);
|
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions);
|
||||||
|
|
||||||
|
@ -122,16 +122,28 @@ public class ProcessSeries : IProcessSeries
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var series2 = await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(firstInfo.LocalizedSeries, string.Empty, library.Id, firstInfo.Format, false);
|
var seriesCollisions = await _unitOfWork.SeriesRepository.GetAllSeriesByAnyName(firstInfo.LocalizedSeries, string.Empty, library.Id, firstInfo.Format);
|
||||||
var details = $"Series 1: {firstInfo.Series} Series 2: {series2.Name}" + "\n" +
|
|
||||||
$"Localized: {firstInfo.LocalizedSeries} Localized: {series2.LocalizedName}" + "\n" +
|
|
||||||
$"Filename: {_directoryService.FileSystem.FileInfo.New(firstInfo.FullFilePath).Directory} Filename: {series2.FolderPath}";
|
|
||||||
_logger.LogError(ex, "Scanner found a Series {SeriesName} which matched another Series {LocalizedName} in a different folder parallel to Library {LibraryName} root folder. This is not allowed. Please correct",
|
|
||||||
firstInfo.Series, firstInfo.LocalizedSeries, library.Name);
|
|
||||||
|
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
seriesCollisions = seriesCollisions.Where(collision =>
|
||||||
MessageFactory.ErrorEvent($"Scanner found a Series {firstInfo.Series} which matched another Series {firstInfo.LocalizedSeries} in a different folder parallel to Library {library.Name} root folder. This is not allowed. Please correct",
|
collision.Name != firstInfo.Series || collision.LocalizedName != firstInfo.LocalizedSeries).ToList();
|
||||||
details));
|
|
||||||
|
if (seriesCollisions.Any())
|
||||||
|
{
|
||||||
|
var tableRows = seriesCollisions.Select(collision =>
|
||||||
|
$"<tr><td>Name: {firstInfo.Series}</td><td>Name: {collision.Name}</td></tr>" +
|
||||||
|
$"<tr><td>Localized: {firstInfo.LocalizedSeries}</td><td>Localized: {collision.LocalizedName}</td></tr>" +
|
||||||
|
$"<tr><td>Filename: {Parser.Parser.NormalizePath(_directoryService.FileSystem.FileInfo.New(firstInfo.FullFilePath).Directory?.ToString())}</td><td>Filename: {Parser.Parser.NormalizePath(collision.FolderPath)}</td></tr>"
|
||||||
|
);
|
||||||
|
|
||||||
|
var htmlTable = $"<table class='table table-striped'><thead><tr><th>Series 1</th><th>Series 2</th></tr></thead><tbody>{string.Join(string.Empty, tableRows)}</tbody></table>";
|
||||||
|
|
||||||
|
_logger.LogError(ex, "Scanner found a Series {SeriesName} which matched another Series {LocalizedName} in a different folder parallel to Library {LibraryName} root folder. This is not allowed. Please correct",
|
||||||
|
firstInfo.Series, firstInfo.LocalizedSeries, library.Name);
|
||||||
|
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||||
|
MessageFactory.ErrorEvent($"Library {library.Name} Series collision on {firstInfo.Series}",
|
||||||
|
htmlTable));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,6 +235,9 @@ public class ProcessSeries : IProcessSeries
|
|||||||
|
|
||||||
if (seriesAdded)
|
if (seriesAdded)
|
||||||
{
|
{
|
||||||
|
// See if any recommendations can link up to the series
|
||||||
|
_logger.LogInformation("Linking up External Recommendations new series (if applicable)");
|
||||||
|
await _unitOfWork.ExternalSeriesMetadataRepository.LinkRecommendationsToSeries(series);
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded,
|
await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded,
|
||||||
MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false);
|
MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false);
|
||||||
}
|
}
|
||||||
|
22
Kavita.Common/Helpers/CronHelper.cs
Normal file
22
Kavita.Common/Helpers/CronHelper.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using System;
|
||||||
|
using Cronos;
|
||||||
|
|
||||||
|
namespace Kavita.Common.Helpers;
|
||||||
|
|
||||||
|
public static class CronHelper
|
||||||
|
{
|
||||||
|
public static bool IsValidCron(string cronExpression)
|
||||||
|
{
|
||||||
|
// NOTE: This must match Hangfire's underlying cron system. Hangfire is unique
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CronExpression.Parse(cronExpression);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
/* Swallow */
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -10,6 +10,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Cronos" Version="0.8.1" />
|
||||||
<PackageReference Include="DotNet.Glob" Version="3.1.3" />
|
<PackageReference Include="DotNet.Glob" Version="3.1.3" />
|
||||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||||
|
14
Kavita.sln
14
Kavita.sln
@ -11,8 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kavita.Common", "Kavita.Com
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API.Benchmark", "API.Benchmark\API.Benchmark.csproj", "{3D781D18-2452-421F-A81A-59254FEE1FEC}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API.Benchmark", "API.Benchmark\API.Benchmark.csproj", "{3D781D18-2452-421F-A81A-59254FEE1FEC}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kavita.Email", "Kavita.Email\Kavita.Email.csproj", "{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -74,17 +72,5 @@ Global
|
|||||||
{3D781D18-2452-421F-A81A-59254FEE1FEC}.Release|x64.Build.0 = Release|Any CPU
|
{3D781D18-2452-421F-A81A-59254FEE1FEC}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{3D781D18-2452-421F-A81A-59254FEE1FEC}.Release|x86.ActiveCfg = Release|Any CPU
|
{3D781D18-2452-421F-A81A-59254FEE1FEC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{3D781D18-2452-421F-A81A-59254FEE1FEC}.Release|x86.Build.0 = Release|Any CPU
|
{3D781D18-2452-421F-A81A-59254FEE1FEC}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
@ -1 +1 @@
|
|||||||
{"zh_Hant":"05191aaae25a26a8597559e8318f97db","zh_Hans":"7edb04f6c2439da2cde73996aed08029","uk":"ccf59f571821ab842882378395ccf48c","tr":"5d6427179210cc370400b816c9d1116d","th":"1e27a1e1cadb2b9f92d85952bffaab95","sk":"24de417448b577b4899e917b70a43263","ru":"c547f0995c167817dd2408e4e9279de2","pt_BR":"5acd3a08c1d9aabfae5a74a438cff79b","pt":"af4162a48f01c5260d6436e7e000c5ef","pl":"c6488fdb9a1ecfe5cde6bd1c264902aa","nl":"3ff322f7b24442bd6bceb5c692146d4f","nb_NO":"99914b932bd37a50b983c5e7c90ae93b","ms":"9fdfcc11a2e8a58a4baa691b93d93ff7","ko":"447e24f9f60e1b9f36bc0b087d059dbd","ja":"27bec4796972f0338404ebdb5829af14","it":"4ef0a0ef56bab4650eda37e0dd841982","id":"cfaff69f0a68d9b6196b6c11986508f8","hi":"d850bb49ec6b5a5ccf9986823f095ab8","fr":"c648c43f9ea0bb20ddb00c0566bbd85a","es":"5816bb68d1d64c40de890c0be0222c71","en":"9ba658d4565ee2d245791896559b2271","de":"c3a4fd22b51fd5a675363a6a35d1611e","cs":"bd76bfbd0e5538378dfe99d034b2adfe"}
|
{"zh_Hant":"05191aaae25a26a8597559e8318f97db","zh_Hans":"7edb04f6c2439da2cde73996aed08029","uk":"ccf59f571821ab842882378395ccf48c","tr":"5d6427179210cc370400b816c9d1116d","th":"1e27a1e1cadb2b9f92d85952bffaab95","sk":"24de417448b577b4899e917b70a43263","ru":"c547f0995c167817dd2408e4e9279de2","pt_BR":"5acd3a08c1d9aabfae5a74a438cff79b","pt":"af4162a48f01c5260d6436e7e000c5ef","pl":"c6488fdb9a1ecfe5cde6bd1c264902aa","nl":"3ff322f7b24442bd6bceb5c692146d4f","nb_NO":"99914b932bd37a50b983c5e7c90ae93b","ms":"9fdfcc11a2e8a58a4baa691b93d93ff7","ko":"447e24f9f60e1b9f36bc0b087d059dbd","ja":"27bec4796972f0338404ebdb5829af14","it":"4ef0a0ef56bab4650eda37e0dd841982","id":"cfaff69f0a68d9b6196b6c11986508f8","hi":"d850bb49ec6b5a5ccf9986823f095ab8","fr":"c648c43f9ea0bb20ddb00c0566bbd85a","es":"5816bb68d1d64c40de890c0be0222c71","en":"9b15b7b325483ec581eca99a4bb93f93","de":"c3a4fd22b51fd5a675363a6a35d1611e","cs":"bd76bfbd0e5538378dfe99d034b2adfe"}
|
3821
UI/Web/package-lock.json
generated
3821
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,16 +14,16 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.8",
|
"@angular/animations": "^17.1.0",
|
||||||
"@angular/cdk": "^17.0.4",
|
"@angular/cdk": "^17.1.0",
|
||||||
"@angular/common": "^17.0.8",
|
"@angular/common": "^17.1.0",
|
||||||
"@angular/compiler": "^17.0.8",
|
"@angular/compiler": "^17.1.0",
|
||||||
"@angular/core": "^17.0.8",
|
"@angular/core": "^17.1.0",
|
||||||
"@angular/forms": "^17.0.8",
|
"@angular/forms": "^17.1.0",
|
||||||
"@angular/localize": "^17.0.8",
|
"@angular/localize": "^17.1.0",
|
||||||
"@angular/platform-browser": "^17.0.8",
|
"@angular/platform-browser": "^17.1.0",
|
||||||
"@angular/platform-browser-dynamic": "^17.0.8",
|
"@angular/platform-browser-dynamic": "^17.1.0",
|
||||||
"@angular/router": "^17.0.8",
|
"@angular/router": "^17.1.0",
|
||||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||||
"@iharbeck/ngx-virtual-scroller": "^17.0.0",
|
"@iharbeck/ngx-virtual-scroller": "^17.0.0",
|
||||||
"@iplab/ngx-file-upload": "^17.0.0",
|
"@iplab/ngx-file-upload": "^17.0.0",
|
||||||
@ -59,20 +59,20 @@
|
|||||||
"zone.js": "^0.14.2"
|
"zone.js": "^0.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^17.0.9",
|
"@angular-devkit/build-angular": "^17.1.0",
|
||||||
"@angular-eslint/builder": "^17.2.0",
|
"@angular-eslint/builder": "^17.2.1",
|
||||||
"@angular-eslint/eslint-plugin": "^17.2.0",
|
"@angular-eslint/eslint-plugin": "^17.2.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "^17.2.0",
|
"@angular-eslint/eslint-plugin-template": "^17.2.1",
|
||||||
"@angular-eslint/schematics": "^17.2.0",
|
"@angular-eslint/schematics": "^17.2.1",
|
||||||
"@angular-eslint/template-parser": "^17.2.0",
|
"@angular-eslint/template-parser": "^17.2.1",
|
||||||
"@angular/cli": "^17.0.9",
|
"@angular/cli": "^17.1.0",
|
||||||
"@angular/compiler-cli": "^17.0.8",
|
"@angular/compiler-cli": "^17.1.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/luxon": "^3.4.0",
|
"@types/luxon": "^3.4.0",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.13.0",
|
"@typescript-eslint/eslint-plugin": "^6.13.0",
|
||||||
"@typescript-eslint/parser": "^6.13.0",
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
"eslint": "^8.54.0",
|
"eslint": "^8.54.0",
|
||||||
"jsonminify": "^0.4.2",
|
"jsonminify": "^0.4.2",
|
||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
@ -80,4 +80,4 @@
|
|||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"webpack-bundle-analyzer": "^4.10.1"
|
"webpack-bundle-analyzer": "^4.10.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -28,7 +28,8 @@ export enum FilterField
|
|||||||
Path = 24,
|
Path = 24,
|
||||||
FilePath = 25,
|
FilePath = 25,
|
||||||
WantToRead = 26,
|
WantToRead = 26,
|
||||||
ReadingDate = 27
|
ReadingDate = 27,
|
||||||
|
AverageRating = 28
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -64,8 +64,10 @@ export class FilterFieldPipe implements PipeTransform {
|
|||||||
return translate('filter-field-pipe.file-path');
|
return translate('filter-field-pipe.file-path');
|
||||||
case FilterField.WantToRead:
|
case FilterField.WantToRead:
|
||||||
return translate('filter-field-pipe.want-to-read');
|
return translate('filter-field-pipe.want-to-read');
|
||||||
case FilterField.ReadingDate:
|
case FilterField.ReadingDate:
|
||||||
return translate('filter-field-pipe.read-date');
|
return translate('filter-field-pipe.read-date');
|
||||||
|
case FilterField.AverageRating:
|
||||||
|
return translate('filter-field-pipe.average-rating');
|
||||||
default:
|
default:
|
||||||
throw new Error(`Invalid FilterField value: ${value}`);
|
throw new Error(`Invalid FilterField value: ${value}`);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,12 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||||
import {NgbActiveModal, NgbRating} from '@ng-bootstrap/ng-bootstrap';
|
import {NgbActiveModal, NgbRating} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { SeriesService } from 'src/app/_services/series.service';
|
import { SeriesService } from 'src/app/_services/series.service';
|
||||||
@ -40,8 +48,8 @@ export class ReviewSeriesModalComponent implements OnInit {
|
|||||||
if (model.reviewBody.length < this.minLength) {
|
if (model.reviewBody.length < this.minLength) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.seriesService.updateReview(this.review.seriesId, model.reviewBody).subscribe(() => {
|
this.seriesService.updateReview(this.review.seriesId, model.reviewBody).subscribe(review => {
|
||||||
this.modal.close({success: true});
|
this.modal.close({success: true, review: review});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -127,13 +127,6 @@ export class DirectoryPickerComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
shareFolder(fullPath: string, event: any) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
this.modal.close({success: true, folderPath: fullPath});
|
|
||||||
}
|
|
||||||
|
|
||||||
share() {
|
share() {
|
||||||
this.modal.close({success: true, folderPath: this.path});
|
this.modal.close({success: true, folderPath: this.path});
|
||||||
}
|
}
|
||||||
@ -142,20 +135,6 @@ export class DirectoryPickerComponent implements OnInit {
|
|||||||
this.modal.close({success: false, folderPath: undefined});
|
this.modal.close({success: false, folderPath: undefined});
|
||||||
}
|
}
|
||||||
|
|
||||||
getStem(path: string): string {
|
|
||||||
|
|
||||||
const lastPath = this.routeStack.peek();
|
|
||||||
if (lastPath && lastPath != path) {
|
|
||||||
let replaced = path.replace(lastPath, '');
|
|
||||||
if (replaced.startsWith('/') || replaced.startsWith('\\')) {
|
|
||||||
replaced = replaced.substring(1, replaced.length);
|
|
||||||
}
|
|
||||||
return replaced;
|
|
||||||
}
|
|
||||||
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateTo(index: number) {
|
navigateTo(index: number) {
|
||||||
while(this.routeStack.items.length - 1 > index) {
|
while(this.routeStack.items.length - 1 > index) {
|
||||||
this.routeStack.pop();
|
this.routeStack.pop();
|
||||||
|
@ -6,6 +6,7 @@ export interface ServerSettings {
|
|||||||
cacheDirectory: string;
|
cacheDirectory: string;
|
||||||
taskScan: string;
|
taskScan: string;
|
||||||
taskBackup: string;
|
taskBackup: string;
|
||||||
|
taskCleanup: string;
|
||||||
loggingLevel: string;
|
loggingLevel: string;
|
||||||
port: number;
|
port: number;
|
||||||
ipAddresses: string;
|
ipAddresses: string;
|
||||||
|
@ -28,9 +28,11 @@
|
|||||||
<div class="col-md-6 col-sm-12">
|
<div class="col-md-6 col-sm-12">
|
||||||
<div class="mb-3" style="width:100%">
|
<div class="mb-3" style="width:100%">
|
||||||
<label for="email" class="form-label">{{t('email')}}</label>
|
<label for="email" class="form-label">{{t('email')}}</label>
|
||||||
<input class="form-control" inputmode="email" type="email" id="email" formControlName="email" aria-describedby="email-validations">
|
<input class="form-control" inputmode="email" type="email" id="email"
|
||||||
|
[class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched"
|
||||||
|
formControlName="email" aria-describedby="email-validations">
|
||||||
<div id="email-validations" class="invalid-feedback"
|
<div id="email-validations" class="invalid-feedback"
|
||||||
*ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
|
*ngIf="userForm.dirty || userForm.touched">
|
||||||
<div *ngIf="userForm.get('email')?.errors?.required">
|
<div *ngIf="userForm.get('email')?.errors?.required">
|
||||||
{{t('required')}}
|
{{t('required')}}
|
||||||
</div>
|
</div>
|
||||||
|
@ -73,9 +73,6 @@
|
|||||||
|
|
||||||
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
|
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
|
||||||
<label for="settings-password" class="form-label">{{t('password-label')}}</label>
|
<label for="settings-password" class="form-label">{{t('password-label')}}</label>
|
||||||
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
|
|
||||||
<ng-template #passwordTooltip>{{t('password-tooltip')}}</ng-template>
|
|
||||||
<span class="visually-hidden" id="settings-password-help"><ng-container [ngTemplateOutlet]="usernameTooltip"></ng-container></span>
|
|
||||||
<input type="password" class="form-control" aria-describedby="manga-header" formControlName="password" id="settings-password" />
|
<input type="password" class="form-control" aria-describedby="manga-header" formControlName="password" id="settings-password" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -99,6 +99,10 @@ export class ManageSettingsComponent implements OnInit {
|
|||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
const modelSettings = this.settingsForm.value;
|
const modelSettings = this.settingsForm.value;
|
||||||
modelSettings.bookmarksDirectory = this.serverSettings.bookmarksDirectory;
|
modelSettings.bookmarksDirectory = this.serverSettings.bookmarksDirectory;
|
||||||
|
modelSettings.smtpConfig = this.serverSettings.smtpConfig;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
|
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||||
this.serverSettings = settings;
|
this.serverSettings = settings;
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
|
@ -34,9 +34,13 @@
|
|||||||
<div class="col-4">{{t('source-title')}}</div>
|
<div class="col-4">{{t('source-title')}}</div>
|
||||||
<div class="col"><a href="https://github.com/Kareadita/Kavita" target="_blank" rel="noopener noreferrer">github.com/Kareadita/Kavita</a></div>
|
<div class="col"><a href="https://github.com/Kareadita/Kavita" target="_blank" rel="noopener noreferrer">github.com/Kareadita/Kavita</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">{{t('localization-title')}}</div>
|
||||||
|
<div class="col"><a href="https://hosted.weblate.org/engage/kavita/" target="_blank" rel="noopener noreferrer">Weblate</a><br/></div>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-4">{{t('feature-request-title')}}</div>
|
<div class="col-4">{{t('feature-request-title')}}</div>
|
||||||
<div class="col"><a href="https://feats.kavitareader.com" target="_blank" rel="noopener noreferrer">https://feats.kavitareader.com</a><br/></div>
|
<div class="col"><a href="https://github.com/Kareadita/Kavita/discussions/2529" target="_blank" rel="noopener noreferrer">https://github.com/Kareadita/Kavita/discussions/</a><br/></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -7,8 +7,30 @@
|
|||||||
<ng-template #taskScanTooltip>{{t('library-scan-tooltip')}}</ng-template>
|
<ng-template #taskScanTooltip>{{t('library-scan-tooltip')}}</ng-template>
|
||||||
<span class="visually-hidden" id="settings-tasks-scan-help"><ng-container [ngTemplateOutlet]="taskScanTooltip"></ng-container></span>
|
<span class="visually-hidden" id="settings-tasks-scan-help"><ng-container [ngTemplateOutlet]="taskScanTooltip"></ng-container></span>
|
||||||
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
|
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
|
||||||
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
|
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{t(freq)}}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
@if (settingsForm.get('taskScan')!.value === customOption) {
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
|
||||||
|
<input class="form-control" type="text"
|
||||||
|
id="custom-task-scan" formControlName="taskScanCustom"
|
||||||
|
[class.is-invalid]="settingsForm.get('taskScanCustom')?.invalid && settingsForm.get('taskScanCustom')?.touched"
|
||||||
|
aria-describedby="task-scan-validations">
|
||||||
|
|
||||||
|
@if (settingsForm.dirty || settingsForm.touched) {
|
||||||
|
<div id="task-scan-validations" class="invalid-feedback">
|
||||||
|
<div *ngIf="settingsForm.get('taskScanCustom')?.errors?.required">
|
||||||
|
{{t('required')}}
|
||||||
|
</div>
|
||||||
|
<div *ngIf="settingsForm.get('taskScanCustom')?.errors?.invalidCron">
|
||||||
|
{{t('cron-notation')}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -16,8 +38,65 @@
|
|||||||
<ng-template #taskBackupTooltip>{{t('library-database-backup-tooltip')}}</ng-template>
|
<ng-template #taskBackupTooltip>{{t('library-database-backup-tooltip')}}</ng-template>
|
||||||
<span class="visually-hidden" id="settings-tasks-backup-help"><ng-container [ngTemplateOutlet]="taskBackupTooltip"></ng-container></span>
|
<span class="visually-hidden" id="settings-tasks-backup-help"><ng-container [ngTemplateOutlet]="taskBackupTooltip"></ng-container></span>
|
||||||
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
|
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
|
||||||
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
|
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{t(freq)}}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
@if (settingsForm.get('taskBackup')!.value === customOption) {
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="custom-task-backup" class="form-label">{{t('custom-label')}}</label>
|
||||||
|
<input class="form-control" type="text"
|
||||||
|
id="custom-task-backup" formControlName="taskBackupCustom"
|
||||||
|
[class.is-invalid]="settingsForm.get('taskBackupCustom')?.invalid && settingsForm.get('taskBackupCustom')?.touched"
|
||||||
|
aria-describedby="task-backup-validations">
|
||||||
|
|
||||||
|
@if (settingsForm.dirty || settingsForm.touched) {
|
||||||
|
<div id="task-backup-validations" class="invalid-feedback">
|
||||||
|
<div *ngIf="settingsForm.get('taskBackupCustom')?.errors?.required">
|
||||||
|
{{t('required')}}
|
||||||
|
</div>
|
||||||
|
<div *ngIf="settingsForm.get('taskBackupCustom')?.errors?.invalidCron">
|
||||||
|
{{t('cron-notation')}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="settings-tasks-cleanup" class="form-label">{{t('cleanup-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="taskCleanupTooltip" role="button" tabindex="0"></i>
|
||||||
|
<ng-template #taskCleanupTooltip>{{t('cleanup-tooltip')}}</ng-template>
|
||||||
|
<span class="visually-hidden" id="settings-tasks-cleanup-help"><ng-container [ngTemplateOutlet]="taskCleanupTooltip"></ng-container></span>
|
||||||
|
<select class="form-select" aria-describedby="settings-tasks-cleanup-help" formControlName="taskCleanup" id="settings-tasks-cleanup">
|
||||||
|
<option *ngFor="let freq of taskFrequenciesForCleanup" [value]="freq">{{t(freq)}}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
@if (settingsForm.get('taskCleanup')!.value === customOption) {
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="custom-task-cleanup" class="form-label">{{t('custom-label')}}</label>
|
||||||
|
<input class="form-control" type="text"
|
||||||
|
id="custom-task-cleanup" formControlName="taskCleanupCustom"
|
||||||
|
[class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched"
|
||||||
|
aria-describedby="task-cleanup-validations">
|
||||||
|
|
||||||
|
@if (settingsForm.dirty || settingsForm.touched) {
|
||||||
|
<div id="task-cleanup-validations" class="invalid-feedback">
|
||||||
|
<div *ngIf="settingsForm.get('taskCleanupCustom')?.errors?.required">
|
||||||
|
{{t('required')}}
|
||||||
|
</div>
|
||||||
|
<div *ngIf="settingsForm.get('taskCleanupCustom')?.errors?.invalidCron">
|
||||||
|
{{t('cron-notation')}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
|
||||||
|
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
|
||||||
|
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
|
||||||
|
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>{{t('adhoc-tasks-title')}}</h4>
|
<h4>{{t('adhoc-tasks-title')}}</h4>
|
||||||
@ -65,13 +144,6 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
||||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
|
|
||||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
|
|
||||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
|
|
||||||
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||||
import {ToastrService} from 'ngx-toastr';
|
import {ToastrService} from 'ngx-toastr';
|
||||||
import {SettingsService} from '../settings.service';
|
import {SettingsService} from '../settings.service';
|
||||||
import {ServerSettings} from '../_models/server-settings';
|
import {ServerSettings} from '../_models/server-settings';
|
||||||
import {shareReplay, take} from 'rxjs/operators';
|
import {shareReplay, take} from 'rxjs/operators';
|
||||||
import {defer, forkJoin, Observable, of} from 'rxjs';
|
import {debounceTime, defer, distinctUntilChanged, forkJoin, Observable, of, switchMap, tap} from 'rxjs';
|
||||||
import {ServerService} from 'src/app/_services/server.service';
|
import {ServerService} from 'src/app/_services/server.service';
|
||||||
import {Job} from 'src/app/_models/job/job';
|
import {Job} from 'src/app/_models/job/job';
|
||||||
import {UpdateNotificationModalComponent} from 'src/app/shared/update-notification/update-notification-modal.component';
|
import {UpdateNotificationModalComponent} from 'src/app/shared/update-notification/update-notification-modal.component';
|
||||||
@ -12,10 +12,12 @@ import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
|||||||
import {DownloadService} from 'src/app/shared/_services/download.service';
|
import {DownloadService} from 'src/app/shared/_services/download.service';
|
||||||
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
|
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
|
||||||
import {AsyncPipe, DatePipe, NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
import {AsyncPipe, DatePipe, NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||||
|
|
||||||
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
|
|
||||||
interface AdhocTask {
|
interface AdhocTask {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@ -30,15 +32,18 @@ interface AdhocTask {
|
|||||||
styleUrls: ['./manage-tasks-settings.component.scss'],
|
styleUrls: ['./manage-tasks-settings.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe, TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule, UtcToLocalTimePipe]
|
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe,
|
||||||
|
TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule, UtcToLocalTimePipe]
|
||||||
})
|
})
|
||||||
export class ManageTasksSettingsComponent implements OnInit {
|
export class ManageTasksSettingsComponent implements OnInit {
|
||||||
|
|
||||||
private readonly translocoService = inject(TranslocoService);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
serverSettings!: ServerSettings;
|
serverSettings!: ServerSettings;
|
||||||
settingsForm: FormGroup = new FormGroup({});
|
settingsForm: FormGroup = new FormGroup({});
|
||||||
taskFrequencies: Array<string> = [];
|
taskFrequencies: Array<string> = [];
|
||||||
|
taskFrequenciesForCleanup: Array<string> = [];
|
||||||
logLevels: Array<string> = [];
|
logLevels: Array<string> = [];
|
||||||
|
|
||||||
recurringTasks$: Observable<Array<Job>> = of([]);
|
recurringTasks$: Observable<Array<Job>> = of([]);
|
||||||
@ -104,7 +109,7 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
successMessage: '',
|
successMessage: '',
|
||||||
successFunction: (update) => {
|
successFunction: (update) => {
|
||||||
if (update === null) {
|
if (update === null) {
|
||||||
this.toastr.info(this.translocoService.translate('toasts.no-updates'));
|
this.toastr.info(translate('toasts.no-updates'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const modalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
|
const modalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
|
||||||
@ -112,6 +117,7 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
customOption = 'custom';
|
||||||
|
|
||||||
constructor(private settingsService: SettingsService, private toastr: ToastrService,
|
constructor(private settingsService: SettingsService, private toastr: ToastrService,
|
||||||
private serverService: ServerService, private modalService: NgbModal,
|
private serverService: ServerService, private modalService: NgbModal,
|
||||||
@ -124,10 +130,79 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
settings: this.settingsService.getServerSettings()
|
settings: this.settingsService.getServerSettings()
|
||||||
}).subscribe(result => {
|
}).subscribe(result => {
|
||||||
this.taskFrequencies = result.frequencies;
|
this.taskFrequencies = result.frequencies;
|
||||||
|
this.taskFrequencies.push(this.customOption);
|
||||||
|
|
||||||
|
this.taskFrequenciesForCleanup = this.taskFrequencies.filter(f => f !== 'disabled');
|
||||||
|
|
||||||
this.logLevels = result.levels;
|
this.logLevels = result.levels;
|
||||||
this.serverSettings = result.settings;
|
this.serverSettings = result.settings;
|
||||||
this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required]));
|
this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required]));
|
||||||
this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
|
this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
|
||||||
|
this.settingsForm.addControl('taskCleanup', new FormControl(this.serverSettings.taskCleanup, [Validators.required]));
|
||||||
|
|
||||||
|
if (!this.taskFrequencies.includes(this.serverSettings.taskScan)) {
|
||||||
|
this.settingsForm.get('taskScan')?.setValue(this.customOption);
|
||||||
|
this.settingsForm.addControl('taskScanCustom', new FormControl(this.serverSettings.taskScan, [Validators.required]));
|
||||||
|
} else {
|
||||||
|
this.settingsForm.addControl('taskScanCustom', new FormControl('', [Validators.required]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.taskFrequencies.includes(this.serverSettings.taskBackup)) {
|
||||||
|
this.settingsForm.get('taskBackup')?.setValue(this.customOption);
|
||||||
|
this.settingsForm.addControl('taskBackupCustom', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
|
||||||
|
} else {
|
||||||
|
this.settingsForm.addControl('taskBackupCustom', new FormControl('', [Validators.required]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.taskFrequenciesForCleanup.includes(this.serverSettings.taskCleanup)) {
|
||||||
|
this.settingsForm.get('taskCleanup')?.setValue(this.customOption);
|
||||||
|
this.settingsForm.addControl('taskCleanupCustom', new FormControl(this.serverSettings.taskCleanup, [Validators.required]));
|
||||||
|
} else {
|
||||||
|
this.settingsForm.addControl('taskCleanupCustom', new FormControl('', [Validators.required]));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settingsForm.get('taskScanCustom')?.valueChanges.pipe(
|
||||||
|
debounceTime(100),
|
||||||
|
switchMap(val => this.settingsService.isValidCronExpression(val)),
|
||||||
|
tap(isValid => {
|
||||||
|
if (isValid) {
|
||||||
|
this.settingsForm.get('taskScanCustom')?.setErrors(null);
|
||||||
|
} else {
|
||||||
|
this.settingsForm.get('taskScanCustom')?.setErrors({invalidCron: true})
|
||||||
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
this.settingsForm.get('taskBackupCustom')?.valueChanges.pipe(
|
||||||
|
debounceTime(100),
|
||||||
|
switchMap(val => this.settingsService.isValidCronExpression(val)),
|
||||||
|
tap(isValid => {
|
||||||
|
if (isValid) {
|
||||||
|
this.settingsForm.get('taskBackupCustom')?.setErrors(null);
|
||||||
|
} else {
|
||||||
|
this.settingsForm.get('taskBackupCustom')?.setErrors({invalidCron: true})
|
||||||
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
this.settingsForm.get('taskCleanupCustom')?.valueChanges.pipe(
|
||||||
|
debounceTime(100),
|
||||||
|
switchMap(val => this.settingsService.isValidCronExpression(val)),
|
||||||
|
tap(isValid => {
|
||||||
|
if (isValid) {
|
||||||
|
this.settingsForm.get('taskCleanupCustom')?.setErrors(null);
|
||||||
|
} else {
|
||||||
|
this.settingsForm.get('taskCleanupCustom')?.setErrors({invalidCron: true})
|
||||||
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -135,9 +210,30 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
resetForm() {
|
resetForm() {
|
||||||
this.settingsForm.get('taskScan')?.setValue(this.serverSettings.taskScan);
|
this.settingsForm.get('taskScan')?.setValue(this.serverSettings.taskScan);
|
||||||
this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup);
|
this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup);
|
||||||
|
this.settingsForm.get('taskCleanup')?.setValue(this.serverSettings.taskCleanup);
|
||||||
|
|
||||||
|
if (!this.taskFrequencies.includes(this.serverSettings.taskScan)) {
|
||||||
|
this.settingsForm.get('taskScanCustom')?.setValue(this.serverSettings.taskScan);
|
||||||
|
} else {
|
||||||
|
this.settingsForm.get('taskScanCustom')?.setValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.taskFrequencies.includes(this.serverSettings.taskBackup)) {
|
||||||
|
this.settingsForm.get('taskBackupCustom')?.setValue(this.serverSettings.taskBackup);
|
||||||
|
} else {
|
||||||
|
this.settingsForm.get('taskBackupCustom')?.setValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.taskFrequencies.includes(this.serverSettings.taskCleanup)) {
|
||||||
|
this.settingsForm.get('taskCleanupCustom')?.setValue(this.serverSettings.taskCleanup);
|
||||||
|
} else {
|
||||||
|
this.settingsForm.get('taskCleanupCustom')?.setValue('');
|
||||||
|
}
|
||||||
|
|
||||||
this.settingsForm.markAsPristine();
|
this.settingsForm.markAsPristine();
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
@ -146,12 +242,26 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
const modelSettings = Object.assign({}, this.serverSettings);
|
const modelSettings = Object.assign({}, this.serverSettings);
|
||||||
modelSettings.taskBackup = this.settingsForm.get('taskBackup')?.value;
|
modelSettings.taskBackup = this.settingsForm.get('taskBackup')?.value;
|
||||||
modelSettings.taskScan = this.settingsForm.get('taskScan')?.value;
|
modelSettings.taskScan = this.settingsForm.get('taskScan')?.value;
|
||||||
|
modelSettings.taskCleanup = this.settingsForm.get('taskCleanup')?.value;
|
||||||
|
|
||||||
|
if (this.serverSettings.taskBackup === this.customOption) {
|
||||||
|
modelSettings.taskBackup = this.settingsForm.get('taskBackupCustom')?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.serverSettings.taskScan === this.customOption) {
|
||||||
|
modelSettings.taskScan = this.settingsForm.get('taskScanCustom')?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.serverSettings.taskScan === this.customOption) {
|
||||||
|
modelSettings.taskCleanup = this.settingsForm.get('taskCleanupCustom')?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||||
this.serverSettings = settings;
|
this.serverSettings = settings;
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay());
|
this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay());
|
||||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
this.toastr.success(translate('toasts.server-settings-updated'));
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}, (err: any) => {
|
}, (err: any) => {
|
||||||
console.error('error: ', err);
|
console.error('error: ', err);
|
||||||
@ -162,7 +272,7 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||||
this.serverSettings = settings;
|
this.serverSettings = settings;
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
this.toastr.success(translate('toasts.server-settings-updated'));
|
||||||
}, (err: any) => {
|
}, (err: any) => {
|
||||||
console.error('error: ', err);
|
console.error('error: ', err);
|
||||||
});
|
});
|
||||||
@ -171,7 +281,7 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
runAdhoc(task: AdhocTask) {
|
runAdhoc(task: AdhocTask) {
|
||||||
task.api.subscribe((data: any) => {
|
task.api.subscribe((data: any) => {
|
||||||
if (task.successMessage.length > 0) {
|
if (task.successMessage.length > 0) {
|
||||||
this.toastr.success(this.translocoService.translate('manage-tasks-settings.' + task.successMessage));
|
this.toastr.success(translate('manage-tasks-settings.' + task.successMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.successFunction) {
|
if (task.successFunction) {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { map } from 'rxjs';
|
import {map, of} from 'rxjs';
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { TextResonse } from '../_types/text-response';
|
import { TextResonse } from '../_types/text-response';
|
||||||
import { ServerSettings } from './_models/server-settings';
|
import { ServerSettings } from './_models/server-settings';
|
||||||
@ -27,10 +27,6 @@ export class SettingsService {
|
|||||||
return this.http.get<ServerSettings>(this.baseUrl + 'settings');
|
return this.http.get<ServerSettings>(this.baseUrl + 'settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseUrl() {
|
|
||||||
return this.http.get<string>(this.baseUrl + 'settings/base-url', TextResonse);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateServerSettings(model: ServerSettings) {
|
updateServerSettings(model: ServerSettings) {
|
||||||
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
|
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
|
||||||
}
|
}
|
||||||
@ -70,4 +66,10 @@ export class SettingsService {
|
|||||||
getOpdsEnabled() {
|
getOpdsEnabled() {
|
||||||
return this.http.get<string>(this.baseUrl + 'settings/opds-enabled', TextResonse).pipe(map(d => d === 'true'));
|
return this.http.get<string>(this.baseUrl + 'settings/opds-enabled', TextResonse).pipe(map(d => d === 'true'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isValidCronExpression(val: string) {
|
||||||
|
if (val === '' || val === undefined || val === null) return of(false);
|
||||||
|
return this.http.get<string>(this.baseUrl + 'settings/is-valid-cron?cronExpression=' + val, TextResonse).pipe(map(d => d === 'true'));
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,10 +30,9 @@
|
|||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="mb-3" style="width: 100%">
|
<div class="mb-3" style="width: 100%">
|
||||||
<label for="sort-name" class="form-label">{{t('sort-name-label')}}</label>
|
<label for="sort-name" class="form-label">{{t('sort-name-label')}}</label>
|
||||||
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}"
|
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}">
|
||||||
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
||||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
<input id="sort-name" class="form-control" formControlName="sortName" type="text" [class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
||||||
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
||||||
<div class="invalid-feedback" *ngIf="errors.required">
|
<div class="invalid-feedback" *ngIf="errors.required">
|
||||||
{{t('required-field')}}
|
{{t('required-field')}}
|
||||||
|
@ -11,7 +11,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||||
import {FilterStatement} from '../../../_models/metadata/v2/filter-statement';
|
import {FilterStatement} from '../../../_models/metadata/v2/filter-statement';
|
||||||
import {BehaviorSubject, distinctUntilChanged, filter, map, Observable, of, startWith, switchMap, tap} from 'rxjs';
|
import {BehaviorSubject, distinctUntilChanged, filter, map, Observable, of, startWith, switchMap} from 'rxjs';
|
||||||
import {MetadataService} from 'src/app/_services/metadata.service';
|
import {MetadataService} from 'src/app/_services/metadata.service';
|
||||||
import {mangaFormatFilters} from 'src/app/_models/metadata/series-filter';
|
import {mangaFormatFilters} from 'src/app/_models/metadata/series-filter';
|
||||||
import {PersonRole} from 'src/app/_models/metadata/person';
|
import {PersonRole} from 'src/app/_models/metadata/person';
|
||||||
@ -53,11 +53,12 @@ class FilterRowUi {
|
|||||||
|
|
||||||
const unitLabels: Map<FilterField, FilterRowUi> = new Map([
|
const unitLabels: Map<FilterField, FilterRowUi> = new Map([
|
||||||
[FilterField.ReadingDate, new FilterRowUi('unit-reading-date')],
|
[FilterField.ReadingDate, new FilterRowUi('unit-reading-date')],
|
||||||
|
[FilterField.AverageRating, new FilterRowUi('unit-average-rating')],
|
||||||
[FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')],
|
[FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
|
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
|
||||||
const NumberFields = [FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, FilterField.UserRating];
|
const NumberFields = [FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, FilterField.UserRating, FilterField.AverageRating];
|
||||||
const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
|
const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
|
||||||
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
|
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
|
||||||
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
|
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
|
||||||
@ -233,7 +234,7 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
} else if (BooleanFields.includes(this.preset.field)) {
|
} else if (BooleanFields.includes(this.preset.field)) {
|
||||||
this.formGroup.get('filterValue')?.patchValue(val);
|
this.formGroup.get('filterValue')?.patchValue(val);
|
||||||
} else if (DateFields.includes(this.preset.field)) {
|
} else if (DateFields.includes(this.preset.field)) {
|
||||||
this.formGroup.get('filterValue')?.patchValue(this.dateParser.parse(val)); // TODO: Figure out how this works
|
this.formGroup.get('filterValue')?.patchValue(this.dateParser.parse(val));
|
||||||
}
|
}
|
||||||
else if (DropdownFields.includes(this.preset.field)) {
|
else if (DropdownFields.includes(this.preset.field)) {
|
||||||
if (this.MultipleDropdownAllowed || val.includes(',')) {
|
if (this.MultipleDropdownAllowed || val.includes(',')) {
|
||||||
@ -332,7 +333,7 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
this.predicateType$.next(PredicateType.Number);
|
this.predicateType$.next(PredicateType.Number);
|
||||||
if (this.loaded) {
|
if (this.loaded) {
|
||||||
this.formGroup.get('filterValue')?.patchValue(0);
|
this.formGroup.get('filterValue')?.patchValue(0);
|
||||||
this.formGroup.get('comparison')?.patchValue(NumberFields[0]);
|
this.formGroup.get('comparison')?.patchValue(NumberComparisons[0]);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -380,11 +381,9 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
|
|
||||||
|
|
||||||
onDateSelect(event: NgbDate) {
|
onDateSelect(event: NgbDate) {
|
||||||
console.log('date selected: ', event);
|
|
||||||
this.propagateFilterUpdate();
|
this.propagateFilterUpdate();
|
||||||
}
|
}
|
||||||
updateIfDateFilled() {
|
updateIfDateFilled() {
|
||||||
console.log('date inputted: ', this.formGroup.get('filterValue')?.value);
|
|
||||||
this.propagateFilterUpdate();
|
this.propagateFilterUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,14 +13,12 @@ import {Rating} from "../../../_models/rating";
|
|||||||
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
||||||
import {NgbPopover, NgbRating} from "@ng-bootstrap/ng-bootstrap";
|
import {NgbPopover, NgbRating} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
||||||
import {AccountService} from "../../../_services/account.service";
|
|
||||||
import {LibraryType} from "../../../_models/library/library";
|
import {LibraryType} from "../../../_models/library/library";
|
||||||
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
|
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
|
||||||
import {NgxStarsModule} from "ngx-stars";
|
import {NgxStarsModule} from "ngx-stars";
|
||||||
import {ThemeService} from "../../../_services/theme.service";
|
import {ThemeService} from "../../../_services/theme.service";
|
||||||
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||||
import {ImageComponent} from "../../../shared/image/image.component";
|
import {ImageComponent} from "../../../shared/image/image.component";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-external-rating',
|
selector: 'app-external-rating',
|
||||||
@ -35,7 +33,6 @@ export class ExternalRatingComponent implements OnInit {
|
|||||||
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly seriesService = inject(SeriesService);
|
private readonly seriesService = inject(SeriesService);
|
||||||
private readonly accountService = inject(AccountService);
|
|
||||||
private readonly themeService = inject(ThemeService);
|
private readonly themeService = inject(ThemeService);
|
||||||
public readonly utilityService = inject(UtilityService);
|
public readonly utilityService = inject(UtilityService);
|
||||||
public readonly destroyRef = inject(DestroyRef);
|
public readonly destroyRef = inject(DestroyRef);
|
||||||
@ -45,30 +42,14 @@ export class ExternalRatingComponent implements OnInit {
|
|||||||
@Input({required: true}) userRating!: number;
|
@Input({required: true}) userRating!: number;
|
||||||
@Input({required: true}) hasUserRated!: boolean;
|
@Input({required: true}) hasUserRated!: boolean;
|
||||||
@Input({required: true}) libraryType!: LibraryType;
|
@Input({required: true}) libraryType!: LibraryType;
|
||||||
|
@Input({required: true}) ratings: Array<Rating> = [];
|
||||||
|
|
||||||
|
|
||||||
ratings: Array<Rating> = [];
|
|
||||||
isLoading: boolean = false;
|
isLoading: boolean = false;
|
||||||
overallRating: number = -1;
|
overallRating: number = -1;
|
||||||
starColor = this.themeService.getCssVariable('--rating-star-color');
|
starColor = this.themeService.getCssVariable('--rating-star-color');
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
|
||||||
this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore);
|
this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore);
|
||||||
|
|
||||||
this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res) => {
|
|
||||||
if (!res) return;
|
|
||||||
this.isLoading = true;
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
this.seriesService.getRatings(this.seriesId).subscribe(res => {
|
|
||||||
this.ratings = res;
|
|
||||||
this.isLoading = false;
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
}, () => {
|
|
||||||
this.isLoading = false;
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRating(rating: number) {
|
updateRating(rating: number) {
|
||||||
|
@ -126,7 +126,7 @@
|
|||||||
@if (seriesMetadata) {
|
@if (seriesMetadata) {
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
||||||
[libraryType]="libraryType"
|
[libraryType]="libraryType" [ratings]="ratings"
|
||||||
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,7 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap';
|
} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {ToastrService} from 'ngx-toastr';
|
import {ToastrService} from 'ngx-toastr';
|
||||||
import {catchError, forkJoin, Observable, of} from 'rxjs';
|
import {catchError, forkJoin, Observable, of} from 'rxjs';
|
||||||
import {filter, map, take} from 'rxjs/operators';
|
import {filter, map, take, tap} from 'rxjs/operators';
|
||||||
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
||||||
import {CardDetailDrawerComponent} from 'src/app/cards/card-detail-drawer/card-detail-drawer.component';
|
import {CardDetailDrawerComponent} from 'src/app/cards/card-detail-drawer/card-detail-drawer.component';
|
||||||
import {EditSeriesModalComponent} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
|
import {EditSeriesModalComponent} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
|
||||||
@ -107,6 +107,7 @@ import {NextExpectedChapter} from "../../../_models/series-detail/next-expected-
|
|||||||
import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-expected-card.component";
|
import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-expected-card.component";
|
||||||
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
||||||
import {MetadataService} from "../../../_services/metadata.service";
|
import {MetadataService} from "../../../_services/metadata.service";
|
||||||
|
import {Rating} from "../../../_models/rating";
|
||||||
|
|
||||||
interface RelatedSeriesPair {
|
interface RelatedSeriesPair {
|
||||||
series: Series;
|
series: Series;
|
||||||
@ -203,6 +204,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
activeTabId = TabID.Storyline;
|
activeTabId = TabID.Storyline;
|
||||||
|
|
||||||
reviews: Array<UserReview> = [];
|
reviews: Array<UserReview> = [];
|
||||||
|
ratings: Array<Rating> = [];
|
||||||
libraryType: LibraryType = LibraryType.Manga;
|
libraryType: LibraryType = LibraryType.Manga;
|
||||||
seriesMetadata: SeriesMetadata | null = null;
|
seriesMetadata: SeriesMetadata | null = null;
|
||||||
readingLists: Array<ReadingList> = [];
|
readingLists: Array<ReadingList> = [];
|
||||||
@ -699,6 +701,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
|
|
||||||
// Reviews
|
// Reviews
|
||||||
this.reviews = [...data.reviews];
|
this.reviews = [...data.reviews];
|
||||||
|
this.ratings = [...data.ratings];
|
||||||
|
|
||||||
// Recommendations
|
// Recommendations
|
||||||
data.recommendations.ownedSeries.map(r => {
|
data.recommendations.ownedSeries.map(r => {
|
||||||
@ -843,6 +846,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
const userReview = this.getUserReview();
|
const userReview = this.getUserReview();
|
||||||
|
|
||||||
const modalRef = this.modalService.open(ReviewSeriesModalComponent, { scrollable: true, size: 'lg' });
|
const modalRef = this.modalService.open(ReviewSeriesModalComponent, { scrollable: true, size: 'lg' });
|
||||||
|
modalRef.componentInstance.series = this.series;
|
||||||
if (userReview.length > 0) {
|
if (userReview.length > 0) {
|
||||||
modalRef.componentInstance.review = userReview[0];
|
modalRef.componentInstance.review = userReview[0];
|
||||||
} else {
|
} else {
|
||||||
@ -852,12 +856,18 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
body: ''
|
body: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
modalRef.componentInstance.series = this.series;
|
|
||||||
modalRef.closed.subscribe((closeResult: {success: boolean}) => {
|
modalRef.closed.subscribe((closeResult) => {
|
||||||
if (closeResult.success) {
|
// BUG: This never executes!
|
||||||
this.loadReviews(); // TODO: Ensure reviews get updated here
|
console.log('Close Result: ')
|
||||||
|
if (closeResult.success && closeResult.review !== null) {
|
||||||
|
const index = this.reviews.findIndex(r => r.username === closeResult.review!.username);
|
||||||
|
console.log('update index: ', index, ' with review ', closeResult.review);
|
||||||
|
this.reviews[index] = closeResult.review;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<app-metadata-detail [tags]="['']" [libraryId]="series.libraryId" [heading]="t('rating-title')">
|
<app-metadata-detail [tags]="['']" [libraryId]="series.libraryId" [heading]="t('rating-title')">
|
||||||
<ng-template #itemTemplate let-item>
|
<ng-template #itemTemplate let-item>
|
||||||
<app-external-rating [seriesId]="series.id" [userRating]="series.userRating" [hasUserRated]="series.hasUserRated" [libraryType]="libraryType"></app-external-rating>
|
<app-external-rating [seriesId]="series.id" [ratings]="ratings" [userRating]="series.userRating" [hasUserRated]="series.hasUserRated" [libraryType]="libraryType"></app-external-rating>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-metadata-detail>
|
</app-metadata-detail>
|
||||||
|
|
||||||
|
@ -9,7 +9,6 @@ import {
|
|||||||
ViewEncapsulation
|
ViewEncapsulation
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {ReaderService} from 'src/app/_services/reader.service';
|
|
||||||
import {TagBadgeComponent, TagBadgeCursor} from '../../../shared/tag-badge/tag-badge.component';
|
import {TagBadgeComponent, TagBadgeCursor} from '../../../shared/tag-badge/tag-badge.component';
|
||||||
import {FilterUtilitiesService} from '../../../shared/_services/filter-utilities.service';
|
import {FilterUtilitiesService} from '../../../shared/_services/filter-utilities.service';
|
||||||
import {Breakpoint, UtilityService} from '../../../shared/_services/utility.service';
|
import {Breakpoint, UtilityService} from '../../../shared/_services/utility.service';
|
||||||
@ -33,6 +32,7 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
|||||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||||
import {ImageComponent} from "../../../shared/image/image.component";
|
import {ImageComponent} from "../../../shared/image/image.component";
|
||||||
|
import {Rating} from "../../../_models/rating";
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -68,6 +68,7 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
|||||||
*/
|
*/
|
||||||
@Input() readingLists: Array<ReadingList> = [];
|
@Input() readingLists: Array<ReadingList> = [];
|
||||||
@Input({required: true}) series!: Series;
|
@Input({required: true}) series!: Series;
|
||||||
|
@Input({required: true}) ratings: Array<Rating> = [];
|
||||||
|
|
||||||
isCollapsed: boolean = true;
|
isCollapsed: boolean = true;
|
||||||
hasExtendedProperties: boolean = false;
|
hasExtendedProperties: boolean = false;
|
||||||
|
@ -62,7 +62,7 @@ export class ConfirmService {
|
|||||||
config.content = content;
|
config.content = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalRef = this.modalService.open(ConfirmDialogComponent);
|
const modalRef = this.modalService.open(ConfirmDialogComponent, {size: "lg", fullscreen: "md"});
|
||||||
modalRef.componentInstance.config = config;
|
modalRef.componentInstance.config = config;
|
||||||
modalRef.closed.pipe(take(1)).subscribe(result => {
|
modalRef.closed.pipe(take(1)).subscribe(result => {
|
||||||
return resolve(result);
|
return resolve(result);
|
||||||
|
@ -1080,14 +1080,14 @@
|
|||||||
"host-name-validation": "Host name must start with http(s) and not end in /",
|
"host-name-validation": "Host name must start with http(s) and not end in /",
|
||||||
|
|
||||||
"sender-address-label": "Sender Address",
|
"sender-address-label": "Sender Address",
|
||||||
"sender-address-tooltip": "",
|
"sender-address-tooltip": "This is the email address from which the receiver will see when they receive the email. Typically the email address associated with the account.",
|
||||||
"sender-displayname-label": "Display Name",
|
"sender-displayname-label": "Display Name",
|
||||||
"sender-displayname-tooltip": "",
|
"sender-displayname-tooltip": "The name from which the receiver will see when they receive the email",
|
||||||
"host-label": "Host",
|
"host-label": "Host",
|
||||||
"host-tooltip": "",
|
"host-tooltip": "Outgoing/SMTP address of your email server",
|
||||||
"port-label": "Port",
|
"port-label": "Port",
|
||||||
"port-tooltip": "",
|
|
||||||
"username-label": "Username",
|
"username-label": "Username",
|
||||||
|
"username-tooltip": "The username used to authenticate against the host",
|
||||||
"password-label": "Password",
|
"password-label": "Password",
|
||||||
"enable-ssl-label": "Use SSL on Email Server",
|
"enable-ssl-label": "Use SSL on Email Server",
|
||||||
"size-limit-label": "Size Limit",
|
"size-limit-label": "Size Limit",
|
||||||
@ -1215,7 +1215,8 @@
|
|||||||
"discord-title": "Discord:",
|
"discord-title": "Discord:",
|
||||||
"donations-title": "Donations:",
|
"donations-title": "Donations:",
|
||||||
"source-title": "Source:",
|
"source-title": "Source:",
|
||||||
"feature-request-title": "Feature Requests"
|
"feature-request-title": "Feature Requests:",
|
||||||
|
"localization-title": "Localizations:"
|
||||||
},
|
},
|
||||||
|
|
||||||
"manage-tasks-settings": {
|
"manage-tasks-settings": {
|
||||||
@ -1224,6 +1225,8 @@
|
|||||||
"library-scan-tooltip": "How often Kavita will scan and refresh metadata around library files.",
|
"library-scan-tooltip": "How often Kavita will scan and refresh metadata around library files.",
|
||||||
"library-database-backup-label": "Library Database Backup",
|
"library-database-backup-label": "Library Database Backup",
|
||||||
"library-database-backup-tooltip": "How often Kavita will backup the database.",
|
"library-database-backup-tooltip": "How often Kavita will backup the database.",
|
||||||
|
"cleanup-label": "Cleanup",
|
||||||
|
"cleanup-tooltip": "How often Kavita will run cleanup tasks. This can be heavy and should be performed at midnight in most cases",
|
||||||
"adhoc-tasks-title": "Ad-hoc Tasks",
|
"adhoc-tasks-title": "Ad-hoc Tasks",
|
||||||
"job-title-header": "Job Title",
|
"job-title-header": "Job Title",
|
||||||
"description-header": "Description",
|
"description-header": "Description",
|
||||||
@ -1231,11 +1234,20 @@
|
|||||||
"reset-to-default": "{{common.reset-to-default}}",
|
"reset-to-default": "{{common.reset-to-default}}",
|
||||||
"reset": "{{common.reset}}",
|
"reset": "{{common.reset}}",
|
||||||
"save": "{{common.save}}",
|
"save": "{{common.save}}",
|
||||||
|
"required": "{{validation.required-field}}",
|
||||||
|
|
||||||
|
"custom-label": "Custom Schedule (Cron Notation)",
|
||||||
|
"cron-notation": "You must use cron notation for custom scheduling",
|
||||||
|
|
||||||
"recurring-tasks-title": "{{title}}",
|
"recurring-tasks-title": "{{title}}",
|
||||||
"last-executed-header": "Last Executed",
|
"last-executed-header": "Last Executed",
|
||||||
"cron-header": "Cron",
|
"cron-header": "Cron",
|
||||||
|
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"daily": "Daily",
|
||||||
|
"weekly": "Weekly",
|
||||||
|
"custom": "Custom",
|
||||||
|
|
||||||
|
|
||||||
"convert-media-task": "Convert Media to Target Encoding",
|
"convert-media-task": "Convert Media to Target Encoding",
|
||||||
"convert-media-task-desc": "Runs a long-running task which will convert all kavita-managed media to the target encoding. This is slow (especially on ARM devices).",
|
"convert-media-task-desc": "Runs a long-running task which will convert all kavita-managed media to the target encoding. This is slow (especially on ARM devices).",
|
||||||
@ -1617,6 +1629,7 @@
|
|||||||
|
|
||||||
"metadata-filter-row": {
|
"metadata-filter-row": {
|
||||||
"unit-reading-date": "Date",
|
"unit-reading-date": "Date",
|
||||||
|
"unit-average-rating": "Average Rating (Kavita+) - only for cached series",
|
||||||
"unit-reading-progress": "Percent"
|
"unit-reading-progress": "Percent"
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1917,7 +1930,8 @@
|
|||||||
"path": "Path",
|
"path": "Path",
|
||||||
"file-path": "File Path",
|
"file-path": "File Path",
|
||||||
"want-to-read": "Want to Read",
|
"want-to-read": "Want to Read",
|
||||||
"read-date": "Reading Date"
|
"read-date": "Reading Date",
|
||||||
|
"average-rating": "Average Rating"
|
||||||
},
|
},
|
||||||
|
|
||||||
"filter-comparison-pipe": {
|
"filter-comparison-pipe": {
|
||||||
|
466
openapi.json
466
openapi.json
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.7.13.0"
|
"version": "0.7.13.1"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -3571,6 +3571,7 @@
|
|||||||
"Metadata"
|
"Metadata"
|
||||||
],
|
],
|
||||||
"summary": "Fetches the details needed from Kavita+ for Series Detail page",
|
"summary": "Fetches the details needed from Kavita+ for Series Detail page",
|
||||||
|
"description": "This will hit upstream K+ if the data in local db is 2 weeks old",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "seriesId",
|
"name": "seriesId",
|
||||||
@ -4601,56 +4602,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/Rating": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Rating"
|
|
||||||
],
|
|
||||||
"summary": "Get the external ratings for a given series",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "seriesId",
|
|
||||||
"in": "query",
|
|
||||||
"description": "",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Success",
|
|
||||||
"content": {
|
|
||||||
"text/plain": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/RatingDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/RatingDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"text/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/RatingDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/Rating/overall": {
|
"/api/Rating/overall": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -6850,47 +6801,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/Recommended/recommendations": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Recommended"
|
|
||||||
],
|
|
||||||
"summary": "For Kavita+ users, this will return recommendations on the server.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "seriesId",
|
|
||||||
"in": "query",
|
|
||||||
"description": "",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Success",
|
|
||||||
"content": {
|
|
||||||
"text/plain": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RecommendationDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RecommendationDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"text/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RecommendationDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/Recommended/quick-reads": {
|
"/api/Recommended/quick-reads": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -7236,54 +7146,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/Review": {
|
"/api/Review": {
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Review"
|
|
||||||
],
|
|
||||||
"summary": "Fetches reviews from the server for a given series",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "seriesId",
|
|
||||||
"in": "query",
|
|
||||||
"description": "",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Success",
|
|
||||||
"content": {
|
|
||||||
"text/plain": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/UserReviewDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/UserReviewDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"text/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/UserReviewDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Review"
|
"Review"
|
||||||
@ -9847,6 +9709,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Settings"
|
"Settings"
|
||||||
],
|
],
|
||||||
|
"summary": "Returns the server settings",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@ -10091,6 +9954,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Settings"
|
"Settings"
|
||||||
],
|
],
|
||||||
|
"summary": "All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@ -10229,6 +10093,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Settings/is-valid-cron": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
],
|
||||||
|
"summary": "Is the cron expression valid for Kavita's scheduler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "cronExpression",
|
||||||
|
"in": "query",
|
||||||
|
"description": "",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Stats/user/{userId}/read": {
|
"/api/Stats/user/{userId}/read": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -14896,6 +14800,184 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Represents if Test Email Service URL was successful or not and if any error occured"
|
"description": "Represents if Test Email Service URL was successful or not and if any error occured"
|
||||||
},
|
},
|
||||||
|
"ExternalRating": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"averageScore": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"favoriteCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"enum": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Misleading name but is the source of data (like a review coming from AniList)",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"providerUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"seriesId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"externalSeriesMetadatas": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ExternalSeriesMetadata"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ExternalRecommendation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"coverUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"aniListId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"malId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"enum": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Misleading name but is the source of data (like a review coming from AniList)",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"seriesId": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "When null, represents an external series. When set, it is a Series",
|
||||||
|
"format": "int32",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"$ref": "#/components/schemas/Series"
|
||||||
|
},
|
||||||
|
"externalSeriesMetadatas": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ExternalSeriesMetadata"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ExternalReview": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"tagline": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"bodyJustText": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Pure text version of the body",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"rawBody": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Raw from the provider. Usually Markdown",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"enum": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Misleading name but is the source of data (like a review coming from AniList)",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"siteUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reviewer's username",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "An Optional Rating coming from the Review",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The media's overall Score",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"totalVotes": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"seriesId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"externalSeriesMetadatas": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ExternalSeriesMetadata"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Represents an Externally supplied Review for a given Series"
|
||||||
|
},
|
||||||
"ExternalSeriesDto": {
|
"ExternalSeriesDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -14938,6 +15020,68 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"ExternalSeriesMetadata": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"externalReviews": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ExternalReview"
|
||||||
|
},
|
||||||
|
"description": "External Reviews for the Series. Managed by Kavita for Kavita+ users",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"externalRatings": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ExternalRating"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"externalRecommendations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ExternalRecommendation"
|
||||||
|
},
|
||||||
|
"description": "External recommendations will include all recommendations and will have a seriesId if it's on this Kavita instance.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"averageExternalRating": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Average External Rating. -1 means not set",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"aniListId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"malId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"googleBooksId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"lastUpdatedUtc": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"$ref": "#/components/schemas/Series"
|
||||||
|
},
|
||||||
|
"seriesId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "External Metadata from Kavita+ for a Series"
|
||||||
|
},
|
||||||
"ExternalSourceDto": {
|
"ExternalSourceDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -15332,7 +15476,8 @@
|
|||||||
24,
|
24,
|
||||||
25,
|
25,
|
||||||
26,
|
26,
|
||||||
27
|
27,
|
||||||
|
28
|
||||||
],
|
],
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Represents the field which will dictate the value type and the Extension used for filtering",
|
"description": "Represents the field which will dictate the value type and the Extension used for filtering",
|
||||||
@ -17534,6 +17679,9 @@
|
|||||||
"metadata": {
|
"metadata": {
|
||||||
"$ref": "#/components/schemas/SeriesMetadata"
|
"$ref": "#/components/schemas/SeriesMetadata"
|
||||||
},
|
},
|
||||||
|
"externalSeriesMetadata": {
|
||||||
|
"$ref": "#/components/schemas/ExternalSeriesMetadata"
|
||||||
|
},
|
||||||
"ratings": {
|
"ratings": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@ -18462,15 +18610,19 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"taskBackup": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"taskCleanup": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"loggingLevel": {
|
"loggingLevel": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Logging level for server. Managed in appsettings.json.",
|
"description": "Logging level for server. Managed in appsettings.json.",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
"taskBackup": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"port": {
|
"port": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Port the server listens on. Managed in appsettings.json.",
|
"description": "Port the server listens on. Managed in appsettings.json.",
|
||||||
@ -20046,6 +20198,11 @@
|
|||||||
"description": "The main review",
|
"description": "The main review",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"bodyJustText": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The main body with just text, for review preview",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"seriesId": {
|
"seriesId": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "The series this is for",
|
"description": "The series this is for",
|
||||||
@ -20061,12 +20218,24 @@
|
|||||||
"description": "The user who wrote this",
|
"description": "The user who wrote this",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"totalVotes": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float"
|
||||||
|
},
|
||||||
|
"rawBody": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"score": {
|
"score": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "How many upvotes this review has gotten",
|
"description": "How many upvotes this review has gotten",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
},
|
},
|
||||||
"externalUrl": {
|
"siteUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "If External, the url of the review",
|
"description": "If External, the url of the review",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
@ -20075,11 +20244,6 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Does this review come from an external Source"
|
"description": "Does this review come from an external Source"
|
||||||
},
|
},
|
||||||
"bodyJustText": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The main body with just text, for review preview",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"provider": {
|
"provider": {
|
||||||
"enum": [
|
"enum": [
|
||||||
0,
|
0,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user