mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-03 05:34:21 -04:00
Stability (I hope) (#2688)
This commit is contained in:
parent
92ad7db918
commit
7e61cca92d
@ -59,8 +59,7 @@ 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
|
||||||
|
|
||||||
|
@ -15,9 +15,8 @@ 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";
|
||||||
public const string KavitaPlusExternalSeries = "kavita+externalSeries";
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Series Detail page for Kavita+ stuff
|
/// External Series metadata for Kavita+ recommendation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string KavitaPlusSeriesDetail = "kavita+seriesDetail";
|
public const string KavitaPlusExternalSeries = "kavita+externalSeries";
|
||||||
}
|
}
|
||||||
|
@ -2,23 +2,18 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Misc;
|
|
||||||
using API.Data.Repositories;
|
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
using API.DTOs.Recommendation;
|
using API.DTOs.Recommendation;
|
||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
using API.Entities;
|
|
||||||
using API.Entities.Enums;
|
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;
|
||||||
|
|
||||||
@ -26,11 +21,10 @@ namespace API.Controllers;
|
|||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILicenseService licenseService,
|
public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService,
|
||||||
IExternalMetadataService metadataService, IEasyCachingProviderFactory cachingProviderFactory)
|
IExternalMetadataService metadataService)
|
||||||
: BaseApiController
|
: BaseApiController
|
||||||
{
|
{
|
||||||
private readonly IEasyCachingProvider _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusSeriesDetail);
|
|
||||||
public const string CacheKey = "kavitaPlusSeriesDetail_";
|
public const string CacheKey = "kavitaPlusSeriesDetail_";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -43,7 +37,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds)
|
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(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();
|
||||||
if (ids != null && ids.Count > 0)
|
if (ids is {Count: > 0})
|
||||||
{
|
{
|
||||||
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
|
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
|
||||||
}
|
}
|
||||||
@ -61,7 +55,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
|
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
|
||||||
{
|
{
|
||||||
return role.HasValue ?
|
return role.HasValue ?
|
||||||
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
|
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role.Value)) :
|
||||||
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
|
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +69,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
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();
|
||||||
if (ids != null && ids.Count > 0)
|
if (ids is {Count: > 0})
|
||||||
{
|
{
|
||||||
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
|
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
|
||||||
}
|
}
|
||||||
@ -92,7 +86,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
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();
|
||||||
if (ids != null && ids.Count > 0)
|
if (ids is {Count: > 0})
|
||||||
{
|
{
|
||||||
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
|
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
|
||||||
}
|
}
|
||||||
@ -110,7 +104,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
|
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(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();
|
||||||
if (ids != null && ids.Count > 0)
|
if (ids is {Count: > 0})
|
||||||
{
|
{
|
||||||
return Ok(await unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
|
return Ok(await unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
|
||||||
}
|
}
|
||||||
@ -184,65 +178,45 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
[HttpGet("chapter-summary")]
|
[HttpGet("chapter-summary")]
|
||||||
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
|
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
|
||||||
{
|
{
|
||||||
|
// TODO: This doesn't seem used anywhere
|
||||||
if (chapterId <= 0) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
if (chapterId <= 0) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||||
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||||
if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||||
return Ok(chapter.Summary);
|
return Ok(chapter.Summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If this Series is on Kavita+ Blacklist, removes it. If already cached, invalidates it.
|
||||||
|
/// This then attempts to refresh data from Kavita+ for this series.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpPost("force-refresh")]
|
||||||
|
public async Task<ActionResult> ForceRefresh(int seriesId)
|
||||||
|
{
|
||||||
|
await metadataService.ForceKavitaPlusRefresh(seriesId);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// <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>
|
/// <remarks>This will hit upstream K+ if the data in local db is 2 weeks old</remarks>
|
||||||
/// <param name="seriesId"></param>
|
/// <param name="seriesId">Series Id</param>
|
||||||
|
/// <param name="libraryType">Library Type</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("series-detail-plus")]
|
[HttpGet("series-detail-plus")]
|
||||||
public async Task<ActionResult<SeriesDetailPlusDto>> GetKavitaPlusSeriesDetailData(int seriesId, LibraryType libraryType, CancellationToken cancellationToken)
|
public async Task<ActionResult<SeriesDetailPlusDto>> GetKavitaPlusSeriesDetailData(int seriesId, LibraryType libraryType)
|
||||||
{
|
{
|
||||||
var userReviews = (await unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, User.GetUserId()))
|
var userReviews = (await unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, User.GetUserId()))
|
||||||
.Where(r => !string.IsNullOrEmpty(r.Body))
|
.Where(r => !string.IsNullOrEmpty(r.Body))
|
||||||
.OrderByDescending(review => review.Username.Equals(User.GetUsername()) ? 1 : 0)
|
.OrderByDescending(review => review.Username.Equals(User.GetUsername()) ? 1 : 0)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var cacheKey = CacheKey + seriesId;
|
var ret = await metadataService.GetSeriesDetailPlus(seriesId, libraryType);
|
||||||
var results = await _cacheProvider.GetAsync<SeriesDetailPlusDto>(cacheKey, cancellationToken);
|
|
||||||
if (results.HasValue)
|
|
||||||
{
|
|
||||||
var cachedResult = results.Value;
|
|
||||||
await PrepareSeriesDetail(userReviews, cachedResult);
|
|
||||||
return cachedResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
SeriesDetailPlusDto? ret = null;
|
|
||||||
if (ExternalMetadataService.IsPlusEligible(libraryType) && await licenseService.HasActiveLicense())
|
|
||||||
{
|
|
||||||
ret = await metadataService.GetSeriesDetailPlus(seriesId);
|
|
||||||
}
|
|
||||||
if (ret == null)
|
|
||||||
{
|
|
||||||
// Cache an empty result, so we don't constantly hit K+ when we know nothing is going to resolve
|
|
||||||
ret = new SeriesDetailPlusDto()
|
|
||||||
{
|
|
||||||
Reviews = new List<UserReviewDto>(),
|
|
||||||
Recommendations = null,
|
|
||||||
Ratings = null
|
|
||||||
};
|
|
||||||
await _cacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromHours(48), cancellationToken);
|
|
||||||
|
|
||||||
var newCacheResult2 = (await _cacheProvider.GetAsync<SeriesDetailPlusDto>(cacheKey)).Value;
|
|
||||||
await PrepareSeriesDetail(userReviews, newCacheResult2);
|
|
||||||
|
|
||||||
return Ok(newCacheResult2);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _cacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromHours(48), cancellationToken);
|
|
||||||
|
|
||||||
// For some reason if we don't use a different instance, the cache keeps changes made below
|
|
||||||
var newCacheResult = (await _cacheProvider.GetAsync<SeriesDetailPlusDto>(cacheKey, cancellationToken)).Value;
|
|
||||||
await PrepareSeriesDetail(userReviews, newCacheResult);
|
|
||||||
|
|
||||||
return Ok(newCacheResult);
|
|
||||||
|
|
||||||
|
await PrepareSeriesDetail(userReviews, ret);
|
||||||
|
return Ok(ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PrepareSeriesDetail(List<UserReviewDto> userReviews, SeriesDetailPlusDto ret)
|
private async Task PrepareSeriesDetail(List<UserReviewDto> userReviews, SeriesDetailPlusDto ret)
|
||||||
@ -253,7 +227,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList()));
|
userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList()));
|
||||||
ret.Reviews = userReviews;
|
ret.Reviews = userReviews;
|
||||||
|
|
||||||
if (!isAdmin && ret.Recommendations != null)
|
if (!isAdmin && ret.Recommendations != null && user != null)
|
||||||
{
|
{
|
||||||
// Re-obtain owned series and take into account age restriction
|
// Re-obtain owned series and take into account age restriction
|
||||||
ret.Recommendations.OwnedSeries =
|
ret.Recommendations.OwnedSeries =
|
||||||
@ -262,7 +236,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
ret.Recommendations.ExternalSeries = new List<ExternalSeriesDto>();
|
ret.Recommendations.ExternalSeries = new List<ExternalSeriesDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ret.Recommendations != null)
|
if (ret.Recommendations != null && user != null)
|
||||||
{
|
{
|
||||||
ret.Recommendations.OwnedSeries ??= new List<SeriesDto>();
|
ret.Recommendations.OwnedSeries ??= new List<SeriesDto>();
|
||||||
await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries);
|
await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries);
|
||||||
|
@ -270,8 +270,6 @@ public class ServerController : BaseApiController
|
|||||||
_logger.LogInformation("Busting Kavita+ Cache");
|
_logger.LogInformation("Busting Kavita+ Cache");
|
||||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
|
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
|
||||||
await provider.FlushAsync();
|
await provider.FlushAsync();
|
||||||
provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusSeriesDetail);
|
|
||||||
await provider.FlushAsync();
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,6 +63,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
public DbSet<ExternalSeriesMetadata> ExternalSeriesMetadata { get; set; } = null!;
|
public DbSet<ExternalSeriesMetadata> ExternalSeriesMetadata { get; set; } = null!;
|
||||||
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
|
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
|
||||||
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
|
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
|
||||||
|
public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!;
|
||||||
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
2874
API/Data/Migrations/20240204141206_BlackListSeries.Designer.cs
generated
Normal file
2874
API/Data/Migrations/20240204141206_BlackListSeries.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
API/Data/Migrations/20240204141206_BlackListSeries.cs
Normal file
57
API/Data/Migrations/20240204141206_BlackListSeries.cs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class BlackListSeries : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "LastUpdatedUtc",
|
||||||
|
table: "ExternalSeriesMetadata",
|
||||||
|
newName: "ValidUntilUtc");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SeriesBlacklist",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
LastChecked = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SeriesBlacklist", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SeriesBlacklist_Series_SeriesId",
|
||||||
|
column: x => x.SeriesId,
|
||||||
|
principalTable: "Series",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SeriesBlacklist_SeriesId",
|
||||||
|
table: "SeriesBlacklist",
|
||||||
|
column: "SeriesId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SeriesBlacklist");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "ValidUntilUtc",
|
||||||
|
table: "ExternalSeriesMetadata",
|
||||||
|
newName: "LastUpdatedUtc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1178,15 +1178,15 @@ namespace API.Data.Migrations
|
|||||||
b.Property<string>("GoogleBooksId")
|
b.Property<string>("GoogleBooksId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<DateTime>("LastUpdatedUtc")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<long>("MalId")
|
b.Property<long>("MalId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("SeriesId")
|
b.Property<int>("SeriesId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ValidUntilUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("SeriesId")
|
b.HasIndex("SeriesId")
|
||||||
@ -1195,6 +1195,25 @@ namespace API.Data.Migrations
|
|||||||
b.ToTable("ExternalSeriesMetadata");
|
b.ToTable("ExternalSeriesMetadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastChecked")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBlacklist");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@ -2393,6 +2412,17 @@ namespace API.Data.Migrations
|
|||||||
b.Navigation("Series");
|
b.Navigation("Series");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Entities.Series", "Series")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("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")
|
||||||
|
@ -27,9 +27,12 @@ public interface IExternalSeriesMetadataRepository
|
|||||||
void Remove(IEnumerable<ExternalRating>? ratings);
|
void Remove(IEnumerable<ExternalRating>? ratings);
|
||||||
void Remove(IEnumerable<ExternalRecommendation>? recommendations);
|
void Remove(IEnumerable<ExternalRecommendation>? recommendations);
|
||||||
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
|
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
|
||||||
Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId, DateTime expireTime);
|
Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId);
|
||||||
Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId);
|
Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId);
|
||||||
Task LinkRecommendationsToSeries(Series series);
|
Task LinkRecommendationsToSeries(Series series);
|
||||||
|
Task<bool> IsBlacklistedSeries(int seriesId);
|
||||||
|
Task CreateBlacklistedSeries(int seriesId);
|
||||||
|
Task RemoveFromBlacklist(int seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepository
|
public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepository
|
||||||
@ -92,12 +95,12 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId, DateTime expireTime)
|
public async Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId)
|
||||||
{
|
{
|
||||||
var row = await _context.ExternalSeriesMetadata
|
var row = await _context.ExternalSeriesMetadata
|
||||||
.Where(s => s.SeriesId == seriesId)
|
.Where(s => s.SeriesId == seriesId)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
return row == null || row.LastUpdatedUtc <= expireTime;
|
return row == null || row.ValidUntilUtc <= DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId)
|
public async Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId)
|
||||||
@ -184,4 +187,41 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
|
|||||||
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<bool> IsBlacklistedSeries(int seriesId)
|
||||||
|
{
|
||||||
|
return _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new instance against SeriesId and Saves to the DB
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId"></param>
|
||||||
|
public async Task CreateBlacklistedSeries(int seriesId)
|
||||||
|
{
|
||||||
|
if (seriesId <= 0) return;
|
||||||
|
await _context.SeriesBlacklist.AddAsync(new SeriesBlacklist()
|
||||||
|
{
|
||||||
|
SeriesId = seriesId
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the Series from Blacklist and Saves to the DB
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId"></param>
|
||||||
|
public async Task RemoveFromBlacklist(int seriesId)
|
||||||
|
{
|
||||||
|
var seriesBlacklist = await _context.SeriesBlacklist.FirstOrDefaultAsync(sb => sb.SeriesId == seriesId);
|
||||||
|
|
||||||
|
if (seriesBlacklist != null)
|
||||||
|
{
|
||||||
|
// Remove the SeriesBlacklist entity from the context
|
||||||
|
_context.SeriesBlacklist.Remove(seriesBlacklist);
|
||||||
|
|
||||||
|
// Save the changes to the database
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,6 +43,7 @@ public interface ILibraryRepository
|
|||||||
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
|
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
|
||||||
IEnumerable<int> GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None);
|
IEnumerable<int> GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None);
|
||||||
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
|
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
|
||||||
|
Task<LibraryType> GetLibraryTypeBySeriesIdAsync(int seriesId);
|
||||||
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IEnumerable<int> libraryIds, LibraryIncludes includes = LibraryIncludes.None);
|
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IEnumerable<int> libraryIds, LibraryIncludes includes = LibraryIncludes.None);
|
||||||
Task<int> GetTotalFiles();
|
Task<int> GetTotalFiles();
|
||||||
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
|
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
|
||||||
@ -54,6 +55,7 @@ public interface ILibraryRepository
|
|||||||
Task<IList<string>> GetAllCoverImagesAsync();
|
Task<IList<string>> GetAllCoverImagesAsync();
|
||||||
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
||||||
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
|
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LibraryRepository : ILibraryRepository
|
public class LibraryRepository : ILibraryRepository
|
||||||
@ -142,6 +144,14 @@ public class LibraryRepository : ILibraryRepository
|
|||||||
.FirstAsync();
|
.FirstAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<LibraryType> GetLibraryTypeBySeriesIdAsync(int seriesId)
|
||||||
|
{
|
||||||
|
return await _context.Series
|
||||||
|
.Where(s => s.Id == seriesId)
|
||||||
|
.Select(s => s.Library.Type)
|
||||||
|
.FirstAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Library>> GetLibraryForIdsAsync(IEnumerable<int> libraryIds, LibraryIncludes includes = LibraryIncludes.None)
|
public async Task<IEnumerable<Library>> GetLibraryForIdsAsync(IEnumerable<int> libraryIds, LibraryIncludes includes = LibraryIncludes.None)
|
||||||
{
|
{
|
||||||
return await _context.Library
|
return await _context.Library
|
||||||
|
@ -13,6 +13,7 @@ using API.DTOs.Filtering;
|
|||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
using API.DTOs.ReadingLists;
|
using API.DTOs.ReadingLists;
|
||||||
|
using API.DTOs.Scrobbling;
|
||||||
using API.DTOs.Search;
|
using API.DTOs.Search;
|
||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
using API.DTOs.Settings;
|
using API.DTOs.Settings;
|
||||||
@ -25,6 +26,7 @@ using API.Extensions.QueryExtensions.Filtering;
|
|||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Helpers.Converters;
|
using API.Helpers.Converters;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Services.Plus;
|
||||||
using API.Services.Tasks;
|
using API.Services.Tasks;
|
||||||
using API.Services.Tasks.Scanner;
|
using API.Services.Tasks.Scanner;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
@ -151,7 +153,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);
|
||||||
|
Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SeriesRepository : ISeriesRepository
|
public class SeriesRepository : ISeriesRepository
|
||||||
@ -701,6 +703,30 @@ 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<PlusSeriesDto?> GetPlusSeriesDto(int seriesId)
|
||||||
|
{
|
||||||
|
return await _context.Series
|
||||||
|
.Where(s => s.Id == seriesId)
|
||||||
|
.Select(series => new PlusSeriesDto()
|
||||||
|
{
|
||||||
|
MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||||
|
SeriesName = series.Name,
|
||||||
|
AltSeriesName = series.LocalizedName,
|
||||||
|
AniListId = ScrobblingService.ExtractId<int?>(series.Metadata.WebLinks,
|
||||||
|
ScrobblingService.AniListWeblinkWebsite),
|
||||||
|
MalId = ScrobblingService.ExtractId<long?>(series.Metadata.WebLinks,
|
||||||
|
ScrobblingService.MalWeblinkWebsite),
|
||||||
|
GoogleBooksId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks,
|
||||||
|
ScrobblingService.GoogleBooksWeblinkWebsite),
|
||||||
|
MangaDexId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks,
|
||||||
|
ScrobblingService.MangaDexWeblinkWebsite),
|
||||||
|
VolumeCount = series.Volumes.Count,
|
||||||
|
ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial),
|
||||||
|
Year = series.Metadata.ReleaseYear
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
@ -29,7 +29,10 @@ public class ExternalSeriesMetadata
|
|||||||
public long MalId { get; set; }
|
public long MalId { get; set; }
|
||||||
public string GoogleBooksId { get; set; }
|
public string GoogleBooksId { get; set; }
|
||||||
|
|
||||||
public DateTime LastUpdatedUtc { get; set; }
|
/// <summary>
|
||||||
|
/// Data is valid until this time
|
||||||
|
/// </summary>
|
||||||
|
public DateTime ValidUntilUtc { get; set; }
|
||||||
|
|
||||||
public Series Series { get; set; } = null!;
|
public Series Series { get; set; } = null!;
|
||||||
public int SeriesId { get; set; }
|
public int SeriesId { get; set; }
|
||||||
|
14
API/Entities/Metadata/SeriesBlacklist.cs
Normal file
14
API/Entities/Metadata/SeriesBlacklist.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace API.Entities.Metadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A blacklist of Series for Kavita+
|
||||||
|
/// </summary>
|
||||||
|
public class SeriesBlacklist
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
public Series Series { get; set; }
|
||||||
|
public DateTime LastChecked { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
@ -86,12 +86,11 @@ public static class ApplicationServiceExtensions
|
|||||||
|
|
||||||
// KavitaPlus stuff
|
// KavitaPlus stuff
|
||||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries);
|
options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries);
|
||||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusSeriesDetail);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddMemoryCache(options =>
|
services.AddMemoryCache(options =>
|
||||||
{
|
{
|
||||||
options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 50 MB
|
options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 75 MB
|
||||||
options.CompactionPercentage = 0.1; // LRU compaction (10%)
|
options.CompactionPercentage = 0.1; // LRU compaction (10%)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -14,7 +14,6 @@ public static class LibraryTypeHelper
|
|||||||
LibraryType.Manga => MediaFormat.Manga,
|
LibraryType.Manga => MediaFormat.Manga,
|
||||||
LibraryType.Comic => MediaFormat.Comic,
|
LibraryType.Comic => MediaFormat.Comic,
|
||||||
LibraryType.Book => MediaFormat.LightNovel,
|
LibraryType.Book => MediaFormat.LightNovel,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,6 @@ using API.Entities;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Helpers.Builders;
|
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
@ -48,7 +47,8 @@ internal class SeriesDetailPlusApiDto
|
|||||||
public interface IExternalMetadataService
|
public interface IExternalMetadataService
|
||||||
{
|
{
|
||||||
Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId);
|
Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId);
|
||||||
Task<SeriesDetailPlusDto?> GetSeriesDetailPlus(int seriesId);
|
Task<SeriesDetailPlusDto> GetSeriesDetailPlus(int seriesId, LibraryType libraryType);
|
||||||
|
Task ForceKavitaPlusRefresh(int seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExternalMetadataService : IExternalMetadataService
|
public class ExternalMetadataService : IExternalMetadataService
|
||||||
@ -56,23 +56,54 @@ 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 IMapper _mapper;
|
||||||
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(14);
|
private readonly ILicenseService _licenseService;
|
||||||
|
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30);
|
||||||
|
private readonly SeriesDetailPlusDto _defaultReturn = new()
|
||||||
|
{
|
||||||
|
Recommendations = null,
|
||||||
|
Ratings = ArraySegment<RatingDto>.Empty,
|
||||||
|
Reviews = ArraySegment<UserReviewDto>.Empty
|
||||||
|
};
|
||||||
|
|
||||||
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper)
|
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper, ILicenseService licenseService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
|
_licenseService = licenseService;
|
||||||
|
|
||||||
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
||||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the library type is allowed to interact with Kavita+
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type"></param>
|
||||||
|
/// <returns></returns>
|
||||||
public static bool IsPlusEligible(LibraryType type)
|
public static bool IsPlusEligible(LibraryType type)
|
||||||
{
|
{
|
||||||
return type != LibraryType.Comic;
|
return type != LibraryType.Comic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes from Blacklist and Invalidates the cache
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task ForceKavitaPlusRefresh(int seriesId)
|
||||||
|
{
|
||||||
|
if (!await _licenseService.HasActiveLicense()) return;
|
||||||
|
// Remove from Blacklist if applicable
|
||||||
|
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId);
|
||||||
|
if (!IsPlusEligible(libraryType)) return;
|
||||||
|
await _unitOfWork.ExternalSeriesMetadataRepository.RemoveFromBlacklist(seriesId);
|
||||||
|
var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
|
||||||
|
if (metadata == null) return;
|
||||||
|
metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves Metadata about a Recommended External Series
|
/// Retrieves Metadata about a Recommended External Series
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -102,11 +133,15 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="seriesId"></param>
|
/// <param name="seriesId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<SeriesDetailPlusDto?> GetSeriesDetailPlus(int seriesId)
|
public async Task<SeriesDetailPlusDto> GetSeriesDetailPlus(int seriesId, LibraryType libraryType)
|
||||||
{
|
{
|
||||||
|
if (!IsPlusEligible(libraryType) || !await _licenseService.HasActiveLicense()) return _defaultReturn;
|
||||||
|
|
||||||
|
// Check blacklist (bad matches)
|
||||||
|
if (await _unitOfWork.ExternalSeriesMetadataRepository.IsBlacklistedSeries(seriesId)) return _defaultReturn;
|
||||||
|
|
||||||
var needsRefresh =
|
var needsRefresh =
|
||||||
await _unitOfWork.ExternalSeriesMetadataRepository.ExternalSeriesMetadataNeedsRefresh(seriesId,
|
await _unitOfWork.ExternalSeriesMetadataRepository.ExternalSeriesMetadataNeedsRefresh(seriesId);
|
||||||
DateTime.UtcNow.Subtract(_externalSeriesMetadataCache));
|
|
||||||
|
|
||||||
if (!needsRefresh)
|
if (!needsRefresh)
|
||||||
{
|
{
|
||||||
@ -116,10 +151,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var series =
|
var data = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(seriesId);
|
||||||
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
if (data == null) return _defaultReturn;
|
||||||
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters);
|
|
||||||
if (series == null || series.Library.Type == LibraryType.Comic) return null;
|
|
||||||
|
|
||||||
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||||
var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
|
var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
|
||||||
@ -130,12 +163,13 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||||
.WithHeader("Content-Type", "application/json")
|
.WithHeader("Content-Type", "application/json")
|
||||||
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
||||||
.PostJsonAsync(new PlusSeriesDtoBuilder(series).Build())
|
.PostJsonAsync(data)
|
||||||
.ReceiveJson<SeriesDetailPlusApiDto>();
|
.ReceiveJson<SeriesDetailPlusApiDto>();
|
||||||
|
|
||||||
|
|
||||||
// Clear out existing results
|
// Clear out existing results
|
||||||
var externalSeriesMetadata = await GetExternalSeriesMetadataForSeries(seriesId, series);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||||
|
var externalSeriesMetadata = await GetExternalSeriesMetadataForSeries(seriesId, series!);
|
||||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews);
|
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews);
|
||||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings);
|
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings);
|
||||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations);
|
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations);
|
||||||
@ -157,19 +191,18 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
|
|
||||||
// Recommendations
|
// Recommendations
|
||||||
externalSeriesMetadata.ExternalRecommendations ??= new List<ExternalRecommendation>();
|
externalSeriesMetadata.ExternalRecommendations ??= new List<ExternalRecommendation>();
|
||||||
var recs = await ProcessRecommendations(series, result.Recommendations, externalSeriesMetadata);
|
var recs = await ProcessRecommendations(libraryType, result.Recommendations, externalSeriesMetadata);
|
||||||
|
|
||||||
var extRatings = externalSeriesMetadata.ExternalRatings
|
var extRatings = externalSeriesMetadata.ExternalRatings
|
||||||
.Where(r => r.AverageScore > 0)
|
.Where(r => r.AverageScore > 0)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
externalSeriesMetadata.LastUpdatedUtc = DateTime.UtcNow;
|
externalSeriesMetadata.ValidUntilUtc = DateTime.UtcNow.Add(_externalSeriesMetadataCache);
|
||||||
externalSeriesMetadata.AverageExternalRating = extRatings.Count != 0 ? (int) extRatings
|
externalSeriesMetadata.AverageExternalRating = extRatings.Count != 0 ? (int) extRatings
|
||||||
.Average(r => r.AverageScore) : 0;
|
.Average(r => r.AverageScore) : 0;
|
||||||
|
|
||||||
if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value;
|
if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value;
|
||||||
if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value;
|
if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value;
|
||||||
|
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
return new SeriesDetailPlusDto()
|
return new SeriesDetailPlusDto()
|
||||||
@ -181,9 +214,9 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
}
|
}
|
||||||
catch (FlurlHttpException ex)
|
catch (FlurlHttpException ex)
|
||||||
{
|
{
|
||||||
if (ex.StatusCode == 404)
|
if (ex.StatusCode == 500)
|
||||||
{
|
{
|
||||||
return null;
|
return _defaultReturn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@ -191,7 +224,10 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
_logger.LogError(ex, "An error happened during the request to Kavita+ API");
|
_logger.LogError(ex, "An error happened during the request to Kavita+ API");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
// Blacklist the series as it wasn't found in Kavita+
|
||||||
|
await _unitOfWork.ExternalSeriesMetadataRepository.CreateBlacklistedSeries(seriesId);
|
||||||
|
|
||||||
|
return _defaultReturn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -200,14 +236,16 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
var externalSeriesMetadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
|
var externalSeriesMetadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
|
||||||
if (externalSeriesMetadata != null) return externalSeriesMetadata;
|
if (externalSeriesMetadata != null) return externalSeriesMetadata;
|
||||||
|
|
||||||
externalSeriesMetadata = new ExternalSeriesMetadata();
|
externalSeriesMetadata = new ExternalSeriesMetadata()
|
||||||
|
{
|
||||||
|
SeriesId = seriesId,
|
||||||
|
};
|
||||||
series.ExternalSeriesMetadata = externalSeriesMetadata;
|
series.ExternalSeriesMetadata = externalSeriesMetadata;
|
||||||
externalSeriesMetadata.SeriesId = series.Id;
|
|
||||||
_unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata);
|
_unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata);
|
||||||
return externalSeriesMetadata;
|
return externalSeriesMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<RecommendationDto> ProcessRecommendations(Series series, IEnumerable<MediaRecommendationDto> recs,
|
private async Task<RecommendationDto> ProcessRecommendations(LibraryType libraryType, IEnumerable<MediaRecommendationDto> recs,
|
||||||
ExternalSeriesMetadata externalSeriesMetadata)
|
ExternalSeriesMetadata externalSeriesMetadata)
|
||||||
{
|
{
|
||||||
var recDto = new RecommendationDto()
|
var recDto = new RecommendationDto()
|
||||||
@ -221,7 +259,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
{
|
{
|
||||||
// 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
|
||||||
var seriesForRec = await _unitOfWork.SeriesRepository.GetSeriesDtoByNamesAndMetadataIds(rec.RecommendationNames,
|
var seriesForRec = await _unitOfWork.SeriesRepository.GetSeriesDtoByNamesAndMetadataIds(rec.RecommendationNames,
|
||||||
series.Library.Type, ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, rec.AniListId),
|
libraryType, ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, rec.AniListId),
|
||||||
ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, rec.MalId));
|
ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, rec.MalId));
|
||||||
|
|
||||||
if (seriesForRec != null)
|
if (seriesForRec != null)
|
||||||
|
@ -54,7 +54,6 @@ 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
|
||||||
{
|
{
|
||||||
@ -64,8 +63,7 @@ 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;
|
||||||
@ -73,9 +71,6 @@ public class SeriesService : ISeriesService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_scrobblingService = scrobblingService;
|
_scrobblingService = scrobblingService;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
|
||||||
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusSeriesDetail);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -114,7 +109,6 @@ public class SeriesService : ISeriesService
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
|
public async Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
|
||||||
{
|
{
|
||||||
var hasWebLinksChanged = false;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
|
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
|
||||||
@ -170,8 +164,6 @@ public class SeriesService : ISeriesService
|
|||||||
series.Metadata.WebLinks = string.Empty;
|
series.Metadata.WebLinks = string.Empty;
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
hasWebLinksChanged =
|
|
||||||
series.Metadata.WebLinks != 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))
|
||||||
@ -314,13 +306,6 @@ 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)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Clearing cache as series weblinks may have changed");
|
|
||||||
await _cacheProvider.RemoveAsync(MetadataController.CacheKey + seriesId);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (updateSeriesMetadataDto.CollectionTags == null) return true;
|
if (updateSeriesMetadataDto.CollectionTags == null) return true;
|
||||||
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
|
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
|
||||||
{
|
{
|
||||||
|
@ -17,6 +17,7 @@ const result = {};
|
|||||||
// Remove file if it exists
|
// Remove file if it exists
|
||||||
const cacheBustingFilePath = './i18n-cache-busting.json';
|
const cacheBustingFilePath = './i18n-cache-busting.json';
|
||||||
if (fs.existsSync(cacheBustingFilePath)) {
|
if (fs.existsSync(cacheBustingFilePath)) {
|
||||||
|
console.log('Removing existing file')
|
||||||
fs.unlinkSync(cacheBustingFilePath);
|
fs.unlinkSync(cacheBustingFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,6 +33,10 @@ export class MetadataService {
|
|||||||
return this.httpClient.get<SeriesDetailPlus | null>(this.baseUrl + 'metadata/series-detail-plus?seriesId=' + seriesId + '&libraryType=' + libraryType);
|
return this.httpClient.get<SeriesDetailPlus | null>(this.baseUrl + 'metadata/series-detail-plus?seriesId=' + seriesId + '&libraryType=' + libraryType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
forceRefreshFromPlus(seriesId: number) {
|
||||||
|
return this.httpClient.post(this.baseUrl + 'metadata/force-refresh?seriesId=' + seriesId, {});
|
||||||
|
}
|
||||||
|
|
||||||
getAllAgeRatings(libraries?: Array<number>) {
|
getAllAgeRatings(libraries?: Array<number>) {
|
||||||
let method = 'metadata/age-ratings'
|
let method = 'metadata/age-ratings'
|
||||||
if (libraries != undefined && libraries.length > 0) {
|
if (libraries != undefined && libraries.length > 0) {
|
||||||
|
@ -35,6 +35,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
|
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
|
||||||
<p>{{t('kavita+-desc-part-1')}} <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}} <a href="https://wiki.kavitareader.com/en/kavita-plus#faq" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
|
<p>{{t('kavita+-desc-part-1')}} <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}} <a href="https://wiki.kavitareader.com/en/kavita-plus#faq" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
|
||||||
|
<p>{{t('kavita+-requirement')}} <a [routerLink]="'/announcements'">{{t('kavita+-releases')}}</a></p>
|
||||||
<app-license></app-license>
|
<app-license></app-license>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<ng-container *transloco="let t; read: 'manage-system'">
|
<ng-container *transloco="let t; read: 'manage-system'">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<h3>{{t('title')}}</h3>
|
|
||||||
<hr/>
|
|
||||||
<div class="mb-3" *ngIf="serverInfo">
|
<div class="mb-3" *ngIf="serverInfo">
|
||||||
|
<h3>{{t('title')}}</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>{{t('version-title')}}</dt>
|
<dt>{{t('version-title')}}</dt>
|
||||||
<dd>{{serverInfo.kavitaVersion}}</dd>
|
<dd>{{serverInfo.kavitaVersion}}</dd>
|
||||||
@ -12,36 +12,43 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>{{t('more-info-title')}}</h3>
|
<div class="mb-3">
|
||||||
<hr/>
|
<h3>{{t('more-info-title')}}</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-4">{{t('home-page-title')}}</div>
|
<div class="col-4">{{t('home-page-title')}}</div>
|
||||||
<div class="col"><a href="https://www.kavitareader.com" target="_blank" rel="noopener noreferrer">kavitareader.com</a></div>
|
<div class="col"><a href="https://www.kavitareader.com" target="_blank" rel="noopener noreferrer">kavitareader.com</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">{{t('wiki-title')}}</div>
|
||||||
|
<div class="col"><a href="https://wiki.kavitareader.com" target="_blank" rel="noopener noreferrer">wiki.kavitareader.com</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">{{t('discord-title')}}</div>
|
||||||
|
<div class="col"><a href="https://discord.gg/b52wT37kt7" target="_blank" rel="noopener noreferrer">discord.gg/b52wT37kt7</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">{{t('donations-title')}}</div>
|
||||||
|
<div class="col"><a href="https://opencollective.com/kavita" target="_blank" rel="noopener noreferrer">opencollective.com/kavita</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<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>
|
||||||
|
<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="col-4">{{t('feature-request-title')}}</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 class="row">
|
|
||||||
<div class="col-4">{{t('wiki-title')}}</div>
|
<div class="mb-3">
|
||||||
<div class="col"><a href="https://wiki.kavitareader.com" target="_blank" rel="noopener noreferrer">wiki.kavitareader.com</a></div>
|
<h3>{{t('updates-title')}}</h3>
|
||||||
</div>
|
<app-changelog></app-changelog>
|
||||||
<div class="row">
|
|
||||||
<div class="col-4">{{t('discord-title')}}</div>
|
|
||||||
<div class="col"><a href="https://discord.gg/b52wT37kt7" target="_blank" rel="noopener noreferrer">discord.gg/b52wT37kt7</a></div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-4">{{t('donations-title')}}</div>
|
|
||||||
<div class="col"><a href="https://opencollective.com/kavita" target="_blank" rel="noopener noreferrer">opencollective.com/kavita</a></div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<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>
|
|
||||||
<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="col-4">{{t('feature-request-title')}}</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>
|
||||||
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -3,6 +3,7 @@ import {ServerService} from 'src/app/_services/server.service';
|
|||||||
import {ServerInfoSlim} from '../_models/server-info';
|
import {ServerInfoSlim} from '../_models/server-info';
|
||||||
import {NgIf} from '@angular/common';
|
import {NgIf} from '@angular/common';
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
import {ChangelogComponent} from "../../announcements/_components/changelog/changelog.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manage-system',
|
selector: 'app-manage-system',
|
||||||
@ -10,7 +11,7 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
|||||||
styleUrls: ['./manage-system.component.scss'],
|
styleUrls: ['./manage-system.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [NgIf, TranslocoDirective]
|
imports: [NgIf, TranslocoDirective, ChangelogComponent]
|
||||||
})
|
})
|
||||||
export class ManageSystemComponent implements OnInit {
|
export class ManageSystemComponent implements OnInit {
|
||||||
|
|
||||||
|
@ -55,12 +55,13 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
api: this.serverService.convertMedia(),
|
api: this.serverService.convertMedia(),
|
||||||
successMessage: 'convert-media-task-success'
|
successMessage: 'convert-media-task-success'
|
||||||
},
|
},
|
||||||
{
|
// I removed this as it's not really needed, given that External Recs are the only thing that fill this cache now
|
||||||
name: 'bust-cache-task',
|
// {
|
||||||
description: 'bust-cache-task-desc',
|
// name: 'bust-cache-task',
|
||||||
api: this.serverService.bustCache(),
|
// description: 'bust-cache-task-desc',
|
||||||
successMessage: 'bust-cache-task-success'
|
// api: this.serverService.bustCache(),
|
||||||
},
|
// successMessage: 'bust-cache-task-success'
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
name: 'bust-locale-task',
|
name: 'bust-locale-task',
|
||||||
description: 'bust-locale-task-desc',
|
description: 'bust-locale-task-desc',
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
<div class="card w-100 mb-2" style="width: 18rem;">
|
<div class="card w-100 mb-2" style="width: 18rem;">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h4 class="card-title">{{update.updateTitle}}
|
<h4 class="card-title">{{update.updateTitle}}
|
||||||
|
<span class="badge bg-secondary" *ngIf="isNightly(update)">{{t('nightly', {version: update.currentVersion})}}</span>
|
||||||
<span class="badge bg-secondary" *ngIf="update.updateVersion === update.currentVersion">{{t('installed')}}</span>
|
<span class="badge bg-secondary" *ngIf="update.updateVersion === update.currentVersion">{{t('installed')}}</span>
|
||||||
<span class="badge bg-secondary" *ngIf="update.updateVersion > update.currentVersion">{{t('available')}}</span>
|
<span class="badge bg-secondary" *ngIf="update.updateVersion > update.currentVersion">{{t('available')}}</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
@ -1,29 +1,55 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||||
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
|
import {UpdateVersionEvent} from 'src/app/_models/events/update-version-event';
|
||||||
import { ServerService } from 'src/app/_services/server.service';
|
import {ServerService} from 'src/app/_services/server.service';
|
||||||
import { LoadingComponent } from '../../../shared/loading/loading.component';
|
import {LoadingComponent} from '../../../shared/loading/loading.component';
|
||||||
import { ReadMoreComponent } from '../../../shared/read-more/read-more.component';
|
import {ReadMoreComponent} from '../../../shared/read-more/read-more.component';
|
||||||
import { NgFor, NgIf, DatePipe } from '@angular/common';
|
import {DatePipe, NgFor, NgIf} from '@angular/common';
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-changelog',
|
selector: 'app-changelog',
|
||||||
templateUrl: './changelog.component.html',
|
templateUrl: './changelog.component.html',
|
||||||
styleUrls: ['./changelog.component.scss'],
|
styleUrls: ['./changelog.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [NgFor, NgIf, ReadMoreComponent, LoadingComponent, DatePipe, TranslocoDirective]
|
imports: [NgFor, NgIf, ReadMoreComponent, LoadingComponent, DatePipe, TranslocoDirective],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class ChangelogComponent implements OnInit {
|
export class ChangelogComponent implements OnInit {
|
||||||
|
|
||||||
|
private readonly serverService = inject(ServerService);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
updates: Array<UpdateVersionEvent> = [];
|
updates: Array<UpdateVersionEvent> = [];
|
||||||
isLoading: boolean = true;
|
isLoading: boolean = true;
|
||||||
|
|
||||||
constructor(private serverService: ServerService) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.serverService.getChangelog().subscribe(updates => {
|
this.serverService.getChangelog().subscribe(updates => {
|
||||||
this.updates = updates;
|
this.updates = updates;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isNightly(update: UpdateVersionEvent) {
|
||||||
|
// Split the version numbers into arrays
|
||||||
|
const updateVersionArr = update.updateVersion.split('.');
|
||||||
|
const currentVersionArr = update.currentVersion.split('.');
|
||||||
|
|
||||||
|
// Compare the first three parts of the version numbers
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const updatePart = parseInt(updateVersionArr[i]);
|
||||||
|
const currentPart = parseInt(currentVersionArr[i]);
|
||||||
|
|
||||||
|
// If any part of the update version is less than the corresponding part of the current version, return true
|
||||||
|
if (updatePart < currentPart) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// If any part of the update version is greater than the corresponding part of the current version, return false
|
||||||
|
else if (updatePart > currentPart) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all parts are equal, compare the length of the version numbers
|
||||||
|
return updateVersionArr.length < currentVersionArr.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -460,6 +460,18 @@
|
|||||||
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
|
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
@if (accountService.hasValidLicense$ | async) {
|
||||||
|
<button type="button" class="btn btn-light" (click)="forceScan()" position="above"
|
||||||
|
[ngbTooltip]="t('force-refresh-tooltip')">
|
||||||
|
@if (forceIsLoading) {
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
|
<span class="visually-hidden">loading...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
{{t('force-refresh')}}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
|
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
|
||||||
<button type="submit" class="btn btn-primary" [disabled]="!editSeriesForm.valid" (click)="save()">{{t('save')}}</button>
|
<button type="submit" class="btn btn-primary" [disabled]="!editSeriesForm.valid" (click)="save()">{{t('save')}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -51,10 +51,13 @@ import {PublicationStatusPipe} from "../../../_pipes/publication-status.pipe";
|
|||||||
import {BytesPipe} from "../../../_pipes/bytes.pipe";
|
import {BytesPipe} from "../../../_pipes/bytes.pipe";
|
||||||
import {ImageComponent} from "../../../shared/image/image.component";
|
import {ImageComponent} from "../../../shared/image/image.component";
|
||||||
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
|
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
|
||||||
import {TranslocoModule} from "@ngneat/transloco";
|
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||||
import {TranslocoDatePipe} from "@ngneat/transloco-locale";
|
import {TranslocoDatePipe} from "@ngneat/transloco-locale";
|
||||||
import {UtcToLocalTimePipe} from "../../../_pipes/utc-to-local-time.pipe";
|
import {UtcToLocalTimePipe} from "../../../_pipes/utc-to-local-time.pipe";
|
||||||
import {EditListComponent} from "../../../shared/edit-list/edit-list.component";
|
import {EditListComponent} from "../../../shared/edit-list/edit-list.component";
|
||||||
|
import {AccountService} from "../../../_services/account.service";
|
||||||
|
import {LibraryType} from "../../../_models/library/library";
|
||||||
|
import {ToastrService} from "ngx-toastr";
|
||||||
|
|
||||||
enum TabID {
|
enum TabID {
|
||||||
General = 0,
|
General = 0,
|
||||||
@ -66,6 +69,13 @@ enum TabID {
|
|||||||
Info = 6,
|
Info = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditSeriesModalCloseResult {
|
||||||
|
success: boolean;
|
||||||
|
series: Series;
|
||||||
|
coverImageUpdate: boolean;
|
||||||
|
updateExternal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-edit-series-modal',
|
selector: 'app-edit-series-modal',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@ -112,6 +122,9 @@ export class EditSeriesModalComponent implements OnInit {
|
|||||||
private readonly uploadService = inject(UploadService);
|
private readonly uploadService = inject(UploadService);
|
||||||
private readonly metadataService = inject(MetadataService);
|
private readonly metadataService = inject(MetadataService);
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
public readonly accountService = inject(AccountService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
protected readonly TabID = TabID;
|
protected readonly TabID = TabID;
|
||||||
protected readonly PersonRole = PersonRole;
|
protected readonly PersonRole = PersonRole;
|
||||||
@ -133,7 +146,9 @@ export class EditSeriesModalComponent implements OnInit {
|
|||||||
editSeriesForm!: FormGroup;
|
editSeriesForm!: FormGroup;
|
||||||
libraryName: string | undefined = undefined;
|
libraryName: string | undefined = undefined;
|
||||||
size: number = 0;
|
size: number = 0;
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
hasForcedKPlus = false;
|
||||||
|
forceIsLoading = false;
|
||||||
|
|
||||||
|
|
||||||
// Typeaheads
|
// Typeaheads
|
||||||
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
||||||
@ -502,7 +517,17 @@ export class EditSeriesModalComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.modal.close({success: false, series: undefined, coverImageUpdate: this.coverImageReset});
|
this.modal.close({success: false, series: undefined, coverImageUpdate: this.coverImageReset, updateExternal: this.hasForcedKPlus});
|
||||||
|
}
|
||||||
|
|
||||||
|
forceScan() {
|
||||||
|
this.forceIsLoading = true;
|
||||||
|
this.metadataService.forceRefreshFromPlus(this.series.id).subscribe(() => {
|
||||||
|
this.hasForcedKPlus = true;
|
||||||
|
this.forceIsLoading = false;
|
||||||
|
this.toastr.info(translate('toasts.force-kavita+-refresh-success'));
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchCollectionTags(filter: string = '') {
|
fetchCollectionTags(filter: string = '') {
|
||||||
@ -541,7 +566,7 @@ export class EditSeriesModalComponent implements OnInit {
|
|||||||
this.saveNestedComponents.emit();
|
this.saveNestedComponents.emit();
|
||||||
|
|
||||||
forkJoin(apis).subscribe(results => {
|
forkJoin(apis).subscribe(results => {
|
||||||
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset});
|
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, updateExternal: this.hasForcedKPlus});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +48,10 @@ import {catchError, forkJoin, Observable, of} from 'rxjs';
|
|||||||
import {map, take} from 'rxjs/operators';
|
import {map, take} 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 {
|
||||||
|
EditSeriesModalCloseResult,
|
||||||
|
EditSeriesModalComponent
|
||||||
|
} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
|
||||||
import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component';
|
import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component';
|
||||||
import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service';
|
import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service';
|
||||||
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
|
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||||
@ -839,10 +842,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
openEditSeriesModal() {
|
openEditSeriesModal() {
|
||||||
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'xl' });
|
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'xl' });
|
||||||
modalRef.componentInstance.series = this.series;
|
modalRef.componentInstance.series = this.series;
|
||||||
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
|
modalRef.closed.subscribe((closeResult: EditSeriesModalCloseResult) => {
|
||||||
if (closeResult.success) {
|
if (closeResult.success) {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
this.loadSeries(this.seriesId);
|
this.loadSeries(this.seriesId, closeResult.updateExternal);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (closeResult.coverImageUpdate) {
|
if (closeResult.coverImageUpdate) {
|
||||||
|
@ -559,6 +559,7 @@
|
|||||||
"changelog": {
|
"changelog": {
|
||||||
"installed": "Installed",
|
"installed": "Installed",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"nightly": "Nightly: {{version}}",
|
||||||
"published-label": "Published: ",
|
"published-label": "Published: ",
|
||||||
"available": "Available",
|
"available": "Available",
|
||||||
"description": "If you do not see an {{installed}}",
|
"description": "If you do not see an {{installed}}",
|
||||||
@ -1231,7 +1232,8 @@
|
|||||||
"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:"
|
"localization-title": "Localizations:",
|
||||||
|
"updates-title": "Update History"
|
||||||
},
|
},
|
||||||
|
|
||||||
"manage-tasks-settings": {
|
"manage-tasks-settings": {
|
||||||
@ -1377,7 +1379,9 @@
|
|||||||
"kavita+-tab": "Kavita+",
|
"kavita+-tab": "Kavita+",
|
||||||
"kavita+-desc-part-1": "Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock ",
|
"kavita+-desc-part-1": "Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock ",
|
||||||
"kavita+-desc-part-2": "premium benefits",
|
"kavita+-desc-part-2": "premium benefits",
|
||||||
"kavita+-desc-part-3": "today!"
|
"kavita+-desc-part-3": "today!",
|
||||||
|
"kavita+-requirement": "Kavita+ is designed to work only with the latest release - 2 versions. Anything outside of that is subject to not working.",
|
||||||
|
"kavita+-releases": "See releases"
|
||||||
},
|
},
|
||||||
|
|
||||||
"collection-detail": {
|
"collection-detail": {
|
||||||
@ -1718,9 +1722,9 @@
|
|||||||
"chapter-title": "Chapter:",
|
"chapter-title": "Chapter:",
|
||||||
"volume-num": "{{common.volume-num}}",
|
"volume-num": "{{common.volume-num}}",
|
||||||
"highest-count-tooltip": "Highest Count found across all ComicInfo in the Series",
|
"highest-count-tooltip": "Highest Count found across all ComicInfo in the Series",
|
||||||
"max-issue-tooltip": "Max Issue or Volume field from all ComicInfo in the series"
|
"max-issue-tooltip": "Max Issue or Volume field from all ComicInfo in the series",
|
||||||
|
"force-refresh": "Force Refresh",
|
||||||
|
"force-refresh-tooltip": "Force refresh external metadata from Kavita+"
|
||||||
},
|
},
|
||||||
|
|
||||||
"day-breakdown": {
|
"day-breakdown": {
|
||||||
@ -2060,7 +2064,8 @@
|
|||||||
"smart-filter-updated": "Created/Updated smart filter",
|
"smart-filter-updated": "Created/Updated smart filter",
|
||||||
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key",
|
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key",
|
||||||
"anilist-token-expired": "Your AniList token is expired. Scrobbling will no longer process until you re-generate it in User Settings > Account",
|
"anilist-token-expired": "Your AniList token is expired. Scrobbling will no longer process until you re-generate it in User Settings > Account",
|
||||||
"collection-tag-deleted": "Collection Tag deleted"
|
"collection-tag-deleted": "Collection Tag deleted",
|
||||||
|
"force-kavita+-refresh-success": "Kavita+ external metadata has been invalidated"
|
||||||
},
|
},
|
||||||
|
|
||||||
"actionable": {
|
"actionable": {
|
||||||
|
30
openapi.json
30
openapi.json
@ -3565,6 +3565,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Metadata/force-refresh": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Metadata"
|
||||||
|
],
|
||||||
|
"summary": "If this Series is on Kavita+ Blacklist, removes it. If already cached, invalidates it.\r\nThis then attempts to refresh data from Kavita+ for this series.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "seriesId",
|
||||||
|
"in": "query",
|
||||||
|
"description": "",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Metadata/series-detail-plus": {
|
"/api/Metadata/series-detail-plus": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -3576,7 +3600,7 @@
|
|||||||
{
|
{
|
||||||
"name": "seriesId",
|
"name": "seriesId",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "",
|
"description": "Series Id",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
@ -3585,6 +3609,7 @@
|
|||||||
{
|
{
|
||||||
"name": "libraryType",
|
"name": "libraryType",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Library Type",
|
||||||
"schema": {
|
"schema": {
|
||||||
"enum": [
|
"enum": [
|
||||||
0,
|
0,
|
||||||
@ -15127,8 +15152,9 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
"lastUpdatedUtc": {
|
"validUntilUtc": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
"description": "Data is valid until this time",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
},
|
},
|
||||||
"series": {
|
"series": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user