More Polish (#2665)

This commit is contained in:
Joe Milazzo 2024-01-29 13:44:20 -06:00 committed by GitHub
parent 47547c23dd
commit e8d9a8b3a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 151 additions and 91 deletions

View File

@ -703,6 +703,37 @@ public class ReaderServiceTests
Assert.Equal(-1, nextChapter);
}
// This is commented out because, while valid, I can't solve how to make this pass (https://github.com/Kareadita/Kavita/issues/2099)
// [Fact]
// public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials_FirstIsVolume()
// {
// await ResetDb();
//
// var series = new SeriesBuilder("Test")
// .WithVolume(new VolumeBuilder("0")
// .WithMinNumber(0)
// .WithChapter(new ChapterBuilder("1").Build())
// .WithChapter(new ChapterBuilder("2").Build())
// .Build())
// .WithVolume(new VolumeBuilder("1")
// .WithMinNumber(1)
// .WithChapter(new ChapterBuilder("0").Build())
// .Build())
// .Build();
// series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build();
//
// _context.Series.Add(series);
// _context.AppUser.Add(new AppUser()
// {
// UserName = "majora2007"
// });
//
// await _context.SaveChangesAsync();
//
// var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1);
// Assert.Equal(-1, nextChapter);
// }
// This is commented out because, while valid, I can't solve how to make this pass
// [Fact]
// public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_WithSpecials()

View File

@ -5,11 +5,14 @@ using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Misc;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Metadata;
using API.DTOs.Recommendation;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
@ -193,7 +196,6 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("series-detail-plus")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
public async Task<ActionResult<SeriesDetailPlusDto>> GetKavitaPlusSeriesDetailData(int seriesId)
{
if (!await licenseService.HasActiveLicense())
@ -203,6 +205,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
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)
@ -213,28 +216,35 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
if (results.HasValue)
{
var cachedResult = results.Value;
userReviews.AddRange(cachedResult.Reviews);
cachedResult.Reviews = ReviewService.SelectSpectrumOfReviews(userReviews);
if (!await unitOfWork.UserRepository.IsUserAdminAsync(user))
{
cachedResult.Recommendations.ExternalSeries = new List<ExternalSeriesDto>();
}
await PrepareSeriesDetail(userReviews, cachedResult, user);
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));
await _cacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromHours(48));
if (!await unitOfWork.UserRepository.IsUserAdminAsync(user))
{
ret.Recommendations.ExternalSeries = new List<ExternalSeriesDto>();
}
// For some reason if we don't use a different instance, the cache keeps changes made below
var newCacheResult = (await _cacheProvider.GetAsync<SeriesDetailPlusDto>(cacheKey)).Value;
await PrepareSeriesDetail(userReviews, newCacheResult, user);
return Ok(ret);
return Ok(newCacheResult);
}
private async Task PrepareSeriesDetail(List<UserReviewDto> userReviews, SeriesDetailPlusDto ret, AppUser user)
{
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList()));
ret.Reviews = userReviews;
if (!isAdmin)
{
// Re-obtain owned series and take into account age restriction
ret.Recommendations.OwnedSeries =
await unitOfWork.SeriesRepository.GetSeriesDtoByIdsAsync(
ret.Recommendations.OwnedSeries.Select(s => s.Id), user);
ret.Recommendations.ExternalSeries = new List<ExternalSeriesDto>();
}
}
}

View File

@ -1250,7 +1250,7 @@ public class OpdsController : BaseApiController
if (progress != null)
{
link.LastRead = progress.PageNum;
link.LastReadDate = progress.LastModifiedUtc;
link.LastReadDate = progress.LastModifiedUtc.ToString("o"); // Adhere to ISO 8601
}
link.IsPageStream = true;
return link;

View File

@ -57,7 +57,7 @@ public class PanelsController : BaseApiController
PageNum = 0,
ChapterId = chapterId,
VolumeId = 0,
SeriesId = 0
SeriesId = 0,
});
return Ok(progress);
}

View File

@ -39,7 +39,7 @@ public class FeedLink
/// </summary>
/// <remarks>Attribute MUST conform Atom's Date construct</remarks>
[XmlAttribute("lastReadDate", Namespace = "http://vaemendis.net/opds-pse/ns")]
public DateTime LastReadDate { get; set; }
public string LastReadDate { get; set; }
public bool ShouldSerializeLastReadDate()
{

View File

@ -15,6 +15,11 @@ public class VolumeDto : IHasReadTimeEstimate
public float MaxNumber { get; set; }
/// <inheritdoc cref="Volume.Name"/>
public string Name { get; set; } = default!;
/// <summary>
/// This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14
/// </summary>
[Obsolete("Use MinNumber")]
public float Number { get; set; }
public int Pages { get; set; }
public int PagesRead { get; set; }
public DateTime LastModifiedUtc { get; set; }

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
@ -26,6 +27,7 @@ public interface IExternalSeriesMetadataRepository
void Remove(IEnumerable<ExternalRating>? ratings);
void Remove(IEnumerable<ExternalRecommendation>? recommendations);
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId, int limit = 25);
Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId, DateTime expireTime);
Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId, int libraryId, AppUser user);
Task LinkRecommendationsToSeries(Series series);
}
@ -86,24 +88,22 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
return _context.ExternalSeriesMetadata
.Where(s => s.SeriesId == seriesId)
.Include(s => s.ExternalReviews.Take(limit))
.Include(s => s.ExternalRatings.Take(limit))
.Include(s => s.ExternalRecommendations.Take(limit))
.Include(s => s.ExternalRatings.OrderBy(r => r.AverageScore).Take(limit))
.Include(s => s.ExternalRecommendations.OrderBy(r => r.Id).Take(limit))
.AsSplitQuery()
.FirstOrDefaultAsync();
}
public async Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId, DateTime expireTime)
{
var row = await _context.ExternalSeriesMetadata
.Where(s => s.SeriesId == seriesId)
.FirstOrDefaultAsync();
return row == null || row.LastUpdatedUtc <= expireTime;
}
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)
@ -116,29 +116,30 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
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 externalSeriesRecommendations = seriesDetailDto.ExternalRecommendations
.Where(r => r.SeriesId == null)
.Select(r => _mapper.Map<ExternalSeriesDto>(r))
.ToList();
var ownedIds = seriesDetailDto.ExternalRecommendations
.Where(r => r.SeriesId != null)
.Select(r => r.SeriesId)
.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)
var ownedSeriesRecommendations = await _context.Series
.Where(s => ownedIds.Contains(s.Id))
.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)
Ratings = seriesDetailDto.ExternalRatings
.DefaultIfEmpty()
.Select(r => _mapper.Map<RatingDto>(r)),
Reviews = seriesDetailDto.ExternalReviews
.DefaultIfEmpty()
.OrderByDescending(r => r.Score)
.Select(r =>
{
var ret = _mapper.Map<UserReviewDto>(r);

View File

@ -1,11 +1,9 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Constants;
using API.Data.ManualMigrations;
using API.Data.Misc;
using API.Data.Scanner;
using API.DTOs;
@ -14,7 +12,6 @@ using API.DTOs.Dashboard;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.DTOs.ReadingLists;
using API.DTOs.Search;
using API.DTOs.SeriesDetail;
@ -34,7 +31,6 @@ using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SQLite;
namespace API.Data.Repositories;
@ -95,6 +91,7 @@ public interface ISeriesRepository
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None);
Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId);
Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
Task<IList<SeriesDto>> GetSeriesDtoByIdsAsync(IEnumerable<int> seriesIds, AppUser user);
Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds);
Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds);
Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
@ -569,6 +566,26 @@ public class SeriesRepository : ISeriesRepository
.ToListAsync();
}
public async Task<IList<SeriesDto>> GetSeriesDtoByIdsAsync(IEnumerable<int> seriesIds, AppUser user)
{
var allowedLibraries = await _context.Library
.Where(library => library.AppUsers.Any(x => x.Id == user.Id))
.Select(l => l.Id)
.ToListAsync();
var restriction = new AgeRestriction()
{
AgeRating = user.AgeRestriction,
IncludeUnknowns = user.AgeRestrictionIncludeUnknowns
};
return await _context.Series
.Include(s => s.Metadata)
.Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(restriction)
.AsSplitQuery()
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds)
{
var volumes = await _context.Volume
@ -1889,22 +1906,25 @@ public class SeriesRepository : ISeriesRepository
}
/// <summary>
/// Uses multiple names to find a match against a series then ensures the user has appropriate access to it. If not, returns null.
/// Uses multiple names to find a match against a series. If not, returns null.
/// </summary>
/// <remarks>This does not restrict to the user at all. That is handled at the API level.</remarks>
/// <param name="userId"></param>
/// <param name="names"></param>
/// <returns></returns>
public async Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIdsForUser(int userId, IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl)
{
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var libraryIds = await _context.Library.GetUserLibrariesByType(userId, libraryType).ToListAsync();
var libraryIds = await _context.Library
.Where(lib => lib.Type == libraryType)
.Select(l => l.Id)
.ToListAsync();
var normalizedNames = names.Select(n => n.ToNormalized()).ToList();
SeriesDto? result = null;
if (!string.IsNullOrEmpty(aniListUrl) || !string.IsNullOrEmpty(malUrl))
{
// TODO: I can likely work AniList and MalIds from ExternalSeriesMetadata in here
result = await _context.Series
.RestrictAgainstAgeRestriction(userRating)
.Where(s => !string.IsNullOrEmpty(s.Metadata.WebLinks))
.Where(s => libraryIds.Contains(s.Library.Id))
.WhereIf(!string.IsNullOrEmpty(aniListUrl), s => s.Metadata.WebLinks.Contains(aniListUrl))
@ -1917,7 +1937,6 @@ public class SeriesRepository : ISeriesRepository
if (result != null) return result;
return await _context.Series
.RestrictAgainstAgeRestriction(userRating)
.Where(s => normalizedNames.Contains(s.NormalizedName) ||
normalizedNames.Contains(s.NormalizedLocalizedName))
.Where(s => libraryIds.Contains(s.Library.Id))

View File

@ -25,7 +25,6 @@ public class ExternalReview
/// Reviewer's username
/// </summary>
public string Username { get; set; }
/// <summary>
/// An Optional Rating coming from the Review
/// </summary>
@ -36,6 +35,7 @@ public class ExternalReview
public int Score { get; set; }
public int TotalVotes { get; set; }
public int SeriesId { get; set; }
// Relationships

View File

@ -46,7 +46,8 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.ChapterId, opt => opt.MapFrom(src => src.Bookmark.ChapterId))
.ForMember(dest => dest.Series, opt => opt.MapFrom(src => src.Series));
CreateMap<LibraryDto, Library>();
CreateMap<Volume, VolumeDto>();
CreateMap<Volume, VolumeDto>()
.ForMember(dest => dest.Number, opt => opt.MapFrom(src => src.MinNumber));
CreateMap<MangaFile, MangaFileDto>();
CreateMap<Chapter, ChapterDto>();
CreateMap<Series, SeriesDto>();

View File

@ -101,14 +101,14 @@ public class ExternalMetadataService : IExternalMetadataService
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);
var needsRefresh =
await _unitOfWork.ExternalSeriesMetadataRepository.ExternalSeriesMetadataNeedsRefresh(seriesId,
DateTime.UtcNow.Subtract(_externalSeriesMetadataCache));
if (!needsRefresh)
{
// Convert into DTOs and return
return await SerializeExternalSeriesDetail(seriesId, series.LibraryId, user);
return await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesDetailPlusDto(seriesId, series.LibraryId, user);
}
try
@ -127,6 +127,7 @@ public class ExternalMetadataService : IExternalMetadataService
// Clear out existing results
var externalSeriesMetadata = await GetExternalSeriesMetadataForSeries(seriesId, series);
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews);
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings);
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations);
@ -150,10 +151,13 @@ public class ExternalMetadataService : IExternalMetadataService
externalSeriesMetadata.ExternalRecommendations ??= new List<ExternalRecommendation>();
var recs = await ProcessRecommendations(series, user, result.Recommendations, externalSeriesMetadata);
externalSeriesMetadata.LastUpdatedUtc = DateTime.UtcNow;
externalSeriesMetadata.AverageExternalRating = (int) externalSeriesMetadata.ExternalRatings
var extRatings = externalSeriesMetadata.ExternalRatings
.Where(r => r.AverageScore > 0)
.Average(r => r.AverageScore);
.ToList();
externalSeriesMetadata.LastUpdatedUtc = DateTime.UtcNow;
externalSeriesMetadata.AverageExternalRating = extRatings.Count != 0 ? (int) extRatings
.Average(r => r.AverageScore) : 0;
if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value;
if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value;
@ -164,11 +168,7 @@ public class ExternalMetadataService : IExternalMetadataService
{
Recommendations = recs,
Ratings = result.Ratings,
Reviews = result.Reviews.Select(r =>
{
r.IsExternal = true;
return r;
})
Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map<UserReviewDto>(r))
};
}
catch (FlurlHttpException ex)
@ -186,10 +186,6 @@ public class ExternalMetadataService : IExternalMetadataService
return null;
}
private async Task<SeriesDetailPlusDto?> SerializeExternalSeriesDetail(int seriesId, int libraryId, AppUser user)
{
return await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesDetailPlusDto(seriesId, libraryId, user);
}
private async Task<ExternalSeriesMetadata> GetExternalSeriesMetadataForSeries(int seriesId, Series series)
{

View File

@ -127,13 +127,14 @@ public class ProcessSeries : IProcessSeries
seriesCollisions = seriesCollisions.Where(collision =>
collision.Name != firstInfo.Series || collision.LocalizedName != firstInfo.LocalizedSeries).ToList();
if (seriesCollisions.Any())
if (seriesCollisions.Count > 1)
{
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 firstCollision = seriesCollisions[0];
var secondCollision = seriesCollisions[1];
var tableRows = $"<tr><td>Name: {firstCollision.Name}</td><td>Name: {secondCollision.Name}</td></tr>" +
$"<tr><td>Localized: {firstCollision.LocalizedName}</td><td>Localized: {secondCollision.LocalizedName}</td></tr>" +
$"<tr><td>Filename: {Parser.Parser.NormalizePath(firstCollision.FolderPath)}</td><td>Filename: {Parser.Parser.NormalizePath(secondCollision.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>";

View File

@ -704,22 +704,12 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.ratings = [...data.ratings];
// Recommendations
data.recommendations.ownedSeries.map(r => {
this.seriesService.getMetadata(r.id).subscribe(m => r.summary = m.summary);
});
this.combinedRecs = [...data.recommendations.ownedSeries, ...data.recommendations.externalSeries];
this.hasRecommendations = this.combinedRecs.length > 0;
this.cdRef.markForCheck();
});
}
loadReviews() {
this.seriesService.getReviews(this.seriesId).subscribe(reviews => {
this.reviews = [...reviews];
this.cdRef.markForCheck();
});
}
setContinuePoint() {
this.readerService.hasSeriesProgress(this.seriesId).subscribe(hasProgress => {

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.13.9"
"version": "0.7.13.10"
},
"servers": [
{
@ -20337,6 +20337,12 @@
"type": "string",
"nullable": true
},
"number": {
"type": "number",
"description": "This will map to MinNumber. Number was removed in v0.7.13.8",
"format": "float",
"deprecated": true
},
"pages": {
"type": "integer",
"format": "int32"