Ingest ExternalReviews from K+

Adds a new entity ExternalChapterMetadata, which would allow us to
extend chapters to Recommendations, Ratings, etc in the future
This commit is contained in:
Amelia 2025-04-28 16:19:03 +02:00
parent 749fb24185
commit 052b3f9fe4
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
29 changed files with 647 additions and 137 deletions

View file

@ -15,8 +15,10 @@ using API.Helpers;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Nager.ArticleNumber;
@ -28,13 +30,16 @@ public class ChapterController : BaseApiController
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
private readonly ILogger<ChapterController> _logger;
private readonly IMapper _mapper;
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger)
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger,
IMapper mapper)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_eventHub = eventHub;
_logger = logger;
_mapper = mapper;
}
/// <summary>
@ -392,15 +397,34 @@ public class ChapterController : BaseApiController
return Ok();
}
/// <summary>
/// Get all reviews for this chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("review")]
public async Task<IList<UserReviewDto>> ChapterReviews([FromQuery] int chapterId)
[HttpGet("chapter-detail-plus")]
public async Task<ChapterDetailPlusDto> ChapterDetailPlus([FromQuery] int seriesId, [FromQuery] int chapterId)
{
return await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, User.GetUserId());
var ret = new ChapterDetailPlusDto();
var userReviews = (await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, User.GetUserId()))
.Where(r => !string.IsNullOrEmpty(r.Body))
.OrderByDescending(review => review.Username.Equals(User.GetUsername()) ? 1 : 0)
.ToList();
var ownRating = await _unitOfWork.UserRepository.GetUserRatingAsync(seriesId, User.GetUserId(), chapterId);
if (ownRating != null)
{
ret.Rating = ownRating.Rating;
ret.HasBeenRated = ownRating.HasBeenRated;
}
var externalMetadata = await _unitOfWork.ExternalChapterMetadataRepository.Get(chapterId);
if (externalMetadata != null && externalMetadata.ExternalReviews.Count > 0)
{
var dtos = externalMetadata.ExternalReviews.Select(ex => _mapper.Map<UserReviewDto>(ex)).ToList();
userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(dtos));
}
ret.Reviews = userReviews;
return ret;
}
}

View file

@ -30,21 +30,6 @@ public class ReviewController : BaseApiController
_scrobblingService = scrobblingService;
}
/// <summary>
/// Get all reviews for the series, or chapter
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<IList<UserReviewDto>> GetReviews([FromQuery] int seriesId, [FromQuery] int? chapterId)
{
if (chapterId == null)
{
return await _unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, User.GetUserId());
}
return await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId.Value, User.GetUserId());
}
/// <summary>
/// Updates the review for a given series, or chapter
@ -62,8 +47,8 @@ public class ReviewController : BaseApiController
var rating = ratingBuilder
.WithBody(dto.Body)
.WithSeriesId(dto.SeriesId)
.WithChapterId(dto.ChapterId)
.WithTagline(string.Empty)
.WithRating(dto.Rating)
.Build();
if (rating.Id == 0)

View file

@ -0,0 +1,14 @@
#nullable enable
using System.Collections.Generic;
using API.DTOs.SeriesDetail;
namespace API.DTOs;
public class ChapterDetailPlusDto
{
public float Rating { get; set; }
public bool HasBeenRated { get; set; }
public List<UserReviewDto> Reviews { get; set; }
public List<RatingDto>? Ratings { get; set; }
}

View file

@ -1,4 +1,5 @@
using API.Services.Plus;
using API.Entities.Enums;
using API.Services.Plus;
namespace API.DTOs;
#nullable enable
@ -8,5 +9,6 @@ public class RatingDto
public int AverageScore { get; set; }
public int FavoriteCount { get; set; }
public ScrobbleProvider Provider { get; set; }
public RatingAuthority Authority { get; set; } = RatingAuthority.User;
public string? ProviderUrl { get; set; }
}

View file

@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;

namespace API.DTOs.SeriesDetail;
#nullable enable
@ -7,6 +6,5 @@ public class UpdateUserReviewDto
{
public int SeriesId { get; set; }
public int? ChapterId { get; set; }
public int Rating { get; set; }
public string Body { get; set; }
}

View file

@ -1,4 +1,5 @@
using API.Entities;
using API.Entities.Enums;
using API.Services.Plus;
namespace API.DTOs.SeriesDetail;
@ -38,7 +39,6 @@ public class UserReviewDto
/// </summary>
public string Username { get; set; }
public int TotalVotes { get; set; }
public float Rating { get; set; }
public bool HasBeenRated { get; set; }
public string? RawBody { get; set; }
/// <summary>
@ -58,4 +58,5 @@ public class UserReviewDto
/// If this review is External, which Provider did it come from
/// </summary>
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita;
public RatingAuthority Authority { get; set; } = RatingAuthority.User;
}

View file

@ -78,6 +78,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<EmailHistory> EmailHistory { get; set; } = null!;
public DbSet<MetadataSettings> MetadataSettings { get; set; } = null!;
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
public DbSet<ExternalChapterReview> ExternalChapterReview { get; set; } = null!;
public DbSet<ExternalChapterMetadata> ExternalChapterMetadata { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{

View file

@ -1,48 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ChapterRating : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ChapterId",
table: "AppUserRating",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AppUserRating_ChapterId",
table: "AppUserRating",
column: "ChapterId");
migrationBuilder.AddForeignKey(
name: "FK_AppUserRating_Chapter_ChapterId",
table: "AppUserRating",
column: "ChapterId",
principalTable: "Chapter",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AppUserRating_Chapter_ChapterId",
table: "AppUserRating");
migrationBuilder.DropIndex(
name: "IX_AppUserRating_ChapterId",
table: "AppUserRating");
migrationBuilder.DropColumn(
name: "ChapterId",
table: "AppUserRating");
}
}
}

View file

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20250426173850_ChapterRating")]
[Migration("20250428141809_ChapterRating")]
partial class ChapterRating
{
/// <inheritdoc />
@ -1318,6 +1318,70 @@ namespace API.Data.Migrations
b.ToTable("MediaError");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalChapterMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId")
.IsUnique();
b.ToTable("ExternalChapterMetadata");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalChapterReview", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Authority")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.HasColumnType("TEXT");
b.Property<string>("BodyJustText")
.HasColumnType("TEXT");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("RawBody")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<string>("SiteUrl")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasColumnType("TEXT");
b.Property<int>("TotalVotes")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ExternalChapterReview");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b =>
{
b.Property<int>("Id")
@ -2456,6 +2520,21 @@ namespace API.Data.Migrations
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("ExternalChapterMetadataExternalChapterReview", b =>
{
b.Property<int>("ExternalChapterMetadatasId")
.HasColumnType("INTEGER");
b.Property<int>("ExternalReviewsId")
.HasColumnType("INTEGER");
b.HasKey("ExternalChapterMetadatasId", "ExternalReviewsId");
b.HasIndex("ExternalReviewsId");
b.ToTable("ExternalChapterMetadataExternalChapterReview");
});
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
{
b.Property<int>("ExternalRatingsId")
@ -2919,6 +2998,15 @@ namespace API.Data.Migrations
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalChapterMetadata", b =>
{
b.HasOne("API.Entities.Chapter", null)
.WithOne("ExternalChapterMetadata")
.HasForeignKey("API.Entities.Metadata.ExternalChapterMetadata", "ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
@ -3226,6 +3314,21 @@ namespace API.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("ExternalChapterMetadataExternalChapterReview", b =>
{
b.HasOne("API.Entities.Metadata.ExternalChapterMetadata", null)
.WithMany()
.HasForeignKey("ExternalChapterMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Metadata.ExternalChapterReview", null)
.WithMany()
.HasForeignKey("ExternalReviewsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
{
b.HasOne("API.Entities.Metadata.ExternalRating", null)
@ -3377,6 +3480,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("ExternalChapterMetadata");
b.Navigation("Files");
b.Navigation("People");

View file

@ -0,0 +1,135 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ChapterRating : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ChapterId",
table: "AppUserRating",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "ExternalChapterMetadata",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ChapterId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalChapterMetadata", x => x.Id);
table.ForeignKey(
name: "FK_ExternalChapterMetadata_Chapter_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapter",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExternalChapterReview",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Tagline = table.Column<string>(type: "TEXT", nullable: true),
Body = table.Column<string>(type: "TEXT", nullable: true),
BodyJustText = table.Column<string>(type: "TEXT", nullable: true),
RawBody = table.Column<string>(type: "TEXT", nullable: true),
Provider = table.Column<int>(type: "INTEGER", nullable: false),
Authority = table.Column<int>(type: "INTEGER", nullable: false),
SiteUrl = table.Column<string>(type: "TEXT", nullable: true),
Username = table.Column<string>(type: "TEXT", nullable: true),
Rating = table.Column<int>(type: "INTEGER", nullable: false),
Score = table.Column<int>(type: "INTEGER", nullable: false),
TotalVotes = table.Column<int>(type: "INTEGER", nullable: false),
ChapterId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalChapterReview", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ExternalChapterMetadataExternalChapterReview",
columns: table => new
{
ExternalChapterMetadatasId = table.Column<int>(type: "INTEGER", nullable: false),
ExternalReviewsId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalChapterMetadataExternalChapterReview", x => new { x.ExternalChapterMetadatasId, x.ExternalReviewsId });
table.ForeignKey(
name: "FK_ExternalChapterMetadataExternalChapterReview_ExternalChapterMetadata_ExternalChapterMetadatasId",
column: x => x.ExternalChapterMetadatasId,
principalTable: "ExternalChapterMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExternalChapterMetadataExternalChapterReview_ExternalChapterReview_ExternalReviewsId",
column: x => x.ExternalReviewsId,
principalTable: "ExternalChapterReview",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserRating_ChapterId",
table: "AppUserRating",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_ExternalChapterMetadata_ChapterId",
table: "ExternalChapterMetadata",
column: "ChapterId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExternalChapterMetadataExternalChapterReview_ExternalReviewsId",
table: "ExternalChapterMetadataExternalChapterReview",
column: "ExternalReviewsId");
migrationBuilder.AddForeignKey(
name: "FK_AppUserRating_Chapter_ChapterId",
table: "AppUserRating",
column: "ChapterId",
principalTable: "Chapter",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AppUserRating_Chapter_ChapterId",
table: "AppUserRating");
migrationBuilder.DropTable(
name: "ExternalChapterMetadataExternalChapterReview");
migrationBuilder.DropTable(
name: "ExternalChapterMetadata");
migrationBuilder.DropTable(
name: "ExternalChapterReview");
migrationBuilder.DropIndex(
name: "IX_AppUserRating_ChapterId",
table: "AppUserRating");
migrationBuilder.DropColumn(
name: "ChapterId",
table: "AppUserRating");
}
}
}

View file

@ -1315,6 +1315,70 @@ namespace API.Data.Migrations
b.ToTable("MediaError");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalChapterMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId")
.IsUnique();
b.ToTable("ExternalChapterMetadata");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalChapterReview", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Authority")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.HasColumnType("TEXT");
b.Property<string>("BodyJustText")
.HasColumnType("TEXT");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("RawBody")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<string>("SiteUrl")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasColumnType("TEXT");
b.Property<int>("TotalVotes")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ExternalChapterReview");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b =>
{
b.Property<int>("Id")
@ -2453,6 +2517,21 @@ namespace API.Data.Migrations
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("ExternalChapterMetadataExternalChapterReview", b =>
{
b.Property<int>("ExternalChapterMetadatasId")
.HasColumnType("INTEGER");
b.Property<int>("ExternalReviewsId")
.HasColumnType("INTEGER");
b.HasKey("ExternalChapterMetadatasId", "ExternalReviewsId");
b.HasIndex("ExternalReviewsId");
b.ToTable("ExternalChapterMetadataExternalChapterReview");
});
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
{
b.Property<int>("ExternalRatingsId")
@ -2916,6 +2995,15 @@ namespace API.Data.Migrations
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalChapterMetadata", b =>
{
b.HasOne("API.Entities.Chapter", null)
.WithOne("ExternalChapterMetadata")
.HasForeignKey("API.Entities.Metadata.ExternalChapterMetadata", "ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
@ -3223,6 +3311,21 @@ namespace API.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("ExternalChapterMetadataExternalChapterReview", b =>
{
b.HasOne("API.Entities.Metadata.ExternalChapterMetadata", null)
.WithMany()
.HasForeignKey("ExternalChapterMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Metadata.ExternalChapterReview", null)
.WithMany()
.HasForeignKey("ExternalReviewsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
{
b.HasOne("API.Entities.Metadata.ExternalRating", null)
@ -3374,6 +3477,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("ExternalChapterMetadata");
b.Navigation("Files");
b.Navigation("People");

View file

@ -0,0 +1,45 @@
#nullable enable
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Entities.Metadata;
using API.Extensions.QueryExtensions;
using AutoMapper;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public enum ExternalChapterMetadataIncludes
{
None = 0,
ExternalReviews = 1 << 1,
}
public interface IExternalChapterMetadataRepository
{
void Attach(ExternalChapterMetadata externalChapterMetadata);
void Remove(IEnumerable<ExternalChapterReview>? reviews);
Task<ExternalChapterMetadata?> Get(int chapterId, ExternalChapterMetadataIncludes includes = ExternalChapterMetadataIncludes.ExternalReviews);
}
public class ExternalChapterMetadataRepository(DataContext context, IMapper mapper): IExternalChapterMetadataRepository
{
public void Attach(ExternalChapterMetadata externalChapterMetadata)
{
context.ExternalChapterMetadata.Attach(externalChapterMetadata);
}
public void Remove(IEnumerable<ExternalChapterReview>? reviews)
{
if (reviews == null) return;
context.ExternalChapterReview.RemoveRange(reviews);
}
public async Task<ExternalChapterMetadata?> Get(int chapterId, ExternalChapterMetadataIncludes includes = ExternalChapterMetadataIncludes.ExternalReviews)
{
return await context.ExternalChapterMetadata
.Includes(includes)
.FirstOrDefaultAsync(c => c.ChapterId == chapterId);
}
}

View file

@ -33,6 +33,7 @@ public interface IUnitOfWork
IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
IEmailHistoryRepository EmailHistoryRepository { get; }
IExternalChapterMetadataRepository ExternalChapterMetadataRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -74,6 +75,7 @@ public class UnitOfWork : IUnitOfWork
AppUserExternalSourceRepository = new AppUserExternalSourceRepository(_context, _mapper);
ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper);
EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper);
ExternalChapterMetadataRepository = new ExternalChapterMetadataRepository(_context, _mapper);
}
/// <summary>
@ -103,6 +105,7 @@ public class UnitOfWork : IUnitOfWork
public IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
public IEmailHistoryRepository EmailHistoryRepository { get; }
public IExternalChapterMetadataRepository ExternalChapterMetadataRepository { get; }
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Entities.Metadata;
using API.Entities.Person;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
@ -169,6 +170,8 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
public Volume Volume { get; set; } = null!;
public int VolumeId { get; set; }
public ExternalChapterMetadata ExternalChapterMetadata { get; set; } = null!;
public void UpdateFrom(ParserInfo info)
{
Files ??= new List<MangaFile>();

View file

@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace API.Entities.Metadata;
/// <summary>
/// External Metadata from Kavita+ for a Chapter
/// </summary>
/// <remarks>
/// As apposed to <see cref="ExternalSeriesMetadata"/>,
/// we do not have a ValidUntilUtc, as this is only matched together with the series.
/// </remarks>
public class ExternalChapterMetadata
{
public int Id { get; set; }
public int ChapterId { get; set; }
public ICollection<ExternalChapterReview> ExternalReviews { get; set; } = null!;
}

View file

@ -0,0 +1,45 @@
using System.Collections.Generic;
using API.Entities.Enums;
using API.Services.Plus;
namespace API.Entities.Metadata;
/// <summary>
/// Represents an Externally supplied Review for a given Series
/// </summary>
public class ExternalChapterReview
{
public int Id { get; set; }
public string Tagline { get; set; }
public required string Body { get; set; }
/// <summary>
/// Pure text version of the body
/// </summary>
public required string BodyJustText { get; set; }
/// <summary>
/// Raw from the provider. Usually Markdown
/// </summary>
public string RawBody { get; set; }
public required ScrobbleProvider Provider { get; set; }
public RatingAuthority Authority { get; set; } = RatingAuthority.User;
public string SiteUrl { get; set; }
/// <summary>
/// Reviewer's username
/// </summary>
public string Username { get; set; }
/// <summary>
/// An Optional Rating coming from the Review
/// </summary>
public int Rating { get; set; } = 0;
/// <summary>
/// The media's overall Score
/// </summary>
public int Score { get; set; }
public int TotalVotes { get; set; }
public int ChapterId { get; set; }
// Relationships
public ICollection<ExternalChapterMetadata> ExternalChapterMetadatas { get; set; } = null!;
}

View file

@ -1,6 +1,7 @@
using System.Linq;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Metadata;
using Microsoft.EntityFrameworkCore;
namespace API.Extensions.QueryExtensions;
@ -303,4 +304,14 @@ public static class IncludesExtensions
return query.AsSplitQuery();
}
public static IQueryable<ExternalChapterMetadata> Includes(this IQueryable<ExternalChapterMetadata> query, ExternalChapterMetadataIncludes includeFlags)
{
if (includeFlags.HasFlag(ExternalChapterMetadataIncludes.ExternalReviews))
{
query = query.Include(e => e.ExternalReviews);
}
return query.AsSplitQuery();
}
}

View file

@ -334,11 +334,19 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.IsExternal,
opt =>
opt.MapFrom(src => true));
CreateMap<ExternalChapterReview, UserReviewDto>()
.ForMember(dest => dest.IsExternal,
opt =>
opt.MapFrom(src => true));
CreateMap<UserReviewDto, ExternalReview>()
.ForMember(dest => dest.BodyJustText,
opt =>
opt.MapFrom(src => ReviewHelper.GetCharacters(src.Body)));
CreateMap<UserReviewDto, ExternalChapterReview>()
.ForMember(dest => dest.BodyJustText,
opt =>
opt.MapFrom(src => ReviewHelper.GetCharacters(src.Body)));
CreateMap<ExternalRecommendation, ExternalSeriesDto>();
CreateMap<Series, ManageMatchSeriesDto>()

View file

@ -20,6 +20,12 @@ public class RatingBuilder : IEntityBuilder<AppUserRating>
return this;
}
public RatingBuilder WithChapterId(int? chapterId)
{
_rating.ChapterId = chapterId;
return this;
}
public RatingBuilder WithRating(int rating)
{
_rating.Rating = Math.Clamp(rating, 0, 5);

View file

@ -1085,26 +1085,52 @@ public class ExternalMetadataService : IExternalMetadataService
madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification;
madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification;
madeModification = await UpdateChapterReviews(chapter, settings, potentialMatch) || madeModification;
madeModification = await UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification;
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
}
return madeModification;
}
private async Task<bool> UpdateChapterReviews(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata)
private async Task<bool> UpdateExternalChapterMetadata(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata)
{
if (!settings.Enabled) return false;
if (metadata.UserReviews.Count == 0 && metadata.CriticReviews.Count == 0) return false;
if (metadata.UserReviews.Count == 0 && metadata.CriticReviews.Count == 0)
{
_logger.LogDebug("No external reviews found for chapter {ChapterID}", chapter.Id);
return false;
}
// Clear current ratings
chapter.Ratings.Clear();
var exteralChapterMetadata = await GetOrCreateExternalChapterMetadataForChapter(chapter.Id, chapter);
_unitOfWork.ExternalChapterMetadataRepository.Remove(exteralChapterMetadata.ExternalReviews);
List<ExternalChapterReview> externalReviews = [];
externalReviews.AddRange(metadata.CriticReviews
.Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body))
.Select(r =>
{
var review = _mapper.Map<ExternalChapterReview>(r);
review.ChapterId = chapter.Id;
review.Authority = RatingAuthority.Critic;
return review;
}));
externalReviews.AddRange(metadata.UserReviews
.Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body))
.Select(r =>
{
var review = _mapper.Map<ExternalChapterReview>(r);
review.ChapterId = chapter.Id;
review.Authority = RatingAuthority.User;
return review;
}));
chapter.ExternalChapterMetadata.ExternalReviews = externalReviews;
_logger.LogDebug("Added {Count} reviews for chapter {ChapterId}", externalReviews.Count, chapter.Id);
return true;
}
@ -1562,6 +1588,28 @@ public class ExternalMetadataService : IExternalMetadataService
return externalSeriesMetadata;
}
/// <summary>
/// Gets from DB or creates a new one with just ChapterId
/// </summary>
/// <param name="chapterId"></param>
/// <param name="chapter"></param>
/// <returns></returns>
private async Task<ExternalChapterMetadata> GetOrCreateExternalChapterMetadataForChapter(int chapterId, Chapter chapter)
{
var externalChapterMetadata = await _unitOfWork.ExternalChapterMetadataRepository.Get(chapterId);
if (externalChapterMetadata != null) return externalChapterMetadata;
externalChapterMetadata = new ExternalChapterMetadata()
{
ChapterId = chapterId,
};
chapter.ExternalChapterMetadata = externalChapterMetadata;
_unitOfWork.ExternalChapterMetadataRepository.Attach(externalChapterMetadata);
return externalChapterMetadata;
}
private async Task<RecommendationDto> ProcessRecommendations(LibraryType libraryType, IEnumerable<MediaRecommendationDto> recs,
ExternalSeriesMetadata externalSeriesMetadata)
{

View file

@ -0,0 +1,9 @@
import {UserReview} from "../_single-module/review-card/user-review";
import {Rating} from "./rating";
export type ChapterDetail = {
rating: number;
hasBeenRated: boolean;
reviews: UserReview[];
ratings: Rating[];
};

View file

@ -5,6 +5,7 @@ import {Chapter} from "../_models/chapter";
import {TextResonse} from "../_types/text-response";
import {UserReview} from "../_single-module/review-card/user-review";
import {Rating} from "../_models/rating";
import {ChapterDetail} from "../_models/chapter-detail";
@Injectable({
providedIn: 'root'
@ -31,8 +32,8 @@ export class ChapterService {
return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse);
}
chapterReviews(chapterId: number) {
return this.httpClient.get<Array<UserReview>>(this.baseUrl + 'chapter/review?chapterId='+chapterId);
chapterDetailPlus(seriesId: number, chapterId: number) {
return this.httpClient.get<ChapterDetail>(this.baseUrl + `chapter/chapter-detail-plus?chapterId=${chapterId}&seriesId=${seriesId}`);
}
}

View file

@ -13,13 +13,6 @@ export class ReviewService {
constructor(private httpClient: HttpClient) { }
getReviews(seriesId: number, chapterId?: number) {
if (chapterId) {
return this.httpClient.get<UserReview[]>(this.baseUrl + `review?chapterId=${chapterId}&seriesId=${seriesId}`);
}
return this.httpClient.get<UserReview[]>(this.baseUrl + 'review?seriesId=' + seriesId);
}
deleteReview(seriesId: number, chapterId?: number) {
if (chapterId) {
return this.httpClient.delete(this.baseUrl + `review?chapterId=${chapterId}&seriesId=${seriesId}`);
@ -28,15 +21,15 @@ export class ReviewService {
return this.httpClient.delete(this.baseUrl + 'review?seriesId=' + seriesId);
}
updateReview(seriesId: number, body: string, rating: number, chapterId?: number) {
updateReview(seriesId: number, body: string, chapterId?: number) {
if (chapterId) {
return this.httpClient.post<UserReview>(this.baseUrl + `review?chapterId=${chapterId}&seriesId=${seriesId}`, {
rating, body
return this.httpClient.post<UserReview>(this.baseUrl + `review`, {
seriesId, chapterId, body
});
}
return this.httpClient.post<UserReview>(this.baseUrl + 'review', {
seriesId, rating, body
seriesId, body
});
}

View file

@ -1,12 +1,14 @@
import {ScrobbleProvider} from "../../_services/scrobbling.service";
export enum RatingAuthority {
User = 0,
Critic = 1
}
export interface UserReview {
seriesId: number;
libraryId: number;
volumeId?: number;
chapterId?: number;
rating: number;
hasBeenRated: boolean;
score: number;
username: string;
body: string;
@ -15,4 +17,5 @@ export interface UserReview {
bodyJustText?: string;
siteUrl?: string;
provider: ScrobbleProvider;
authority: RatingAuthority;
}

View file

@ -35,31 +35,21 @@ export class ReviewModalComponent implements OnInit {
protected readonly modal = inject(NgbActiveModal);
private readonly reviewService = inject(ReviewService);
private readonly seriesService = inject(SeriesService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly confirmService = inject(ConfirmService);
private readonly toastr = inject(ToastrService);
private readonly themeService = inject(ThemeService);
protected readonly minLength = 5;
@Input({required: true}) review!: UserReview;
reviewGroup!: FormGroup;
rating: number = 0;
starColor = this.themeService.getCssVariable('--rating-star-color');
ngOnInit(): void {
this.reviewGroup = new FormGroup({
reviewBody: new FormControl(this.review.body, [Validators.required, Validators.minLength(this.minLength)]),
});
this.rating = this.review.rating;
this.cdRef.markForCheck();
}
updateRating($event: number) {
this.rating = $event;
}
close() {
this.modal.close({success: false, review: this.review, action: ReviewModalCloseAction.Close});
}
@ -79,7 +69,7 @@ export class ReviewModalComponent implements OnInit {
return;
}
this.reviewService.updateReview(this.review.seriesId, model.reviewBody, this.rating, this.review.chapterId).subscribe(review => {
this.reviewService.updateReview(this.review.seriesId, model.reviewBody, this.review.chapterId).subscribe(review => {
this.modal.close({success: true, review: review, action: ReviewModalCloseAction.Edit});
});

View file

@ -28,11 +28,10 @@
<div class="mt-2 mb-2">
@let rating = userRating();
<app-external-rating [seriesId]="series.id"
[ratings]="[]"
[userRating]="rating?.rating || 0"
[hasUserRated]="rating !== undefined && rating.hasBeenRated"
[userRating]="rating"
[hasUserRated]="hasBeenRated"
[libraryType]="libraryType!"
[chapterId]="chapterId"
>

View file

@ -73,10 +73,11 @@ import {ReviewModalComponent} from "../_single-module/review-modal/review-modal.
import {ReviewsComponent} from "../_single-module/reviews/reviews.component";
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
import {Rating} from "../_models/rating";
import {ReviewService} from "../_services/review.service";
enum TabID {
Related = 'related-tab',
Reviews = 'review-tab', // Only applicable for books
Reviews = 'review-tab',
Details = 'details-tab'
}
@ -111,8 +112,6 @@ enum TabID {
DatePipe,
DefaultDatePipe,
CoverImageComponent,
CarouselReelComponent,
ReviewCardComponent,
ReviewsComponent,
ExternalRatingComponent
],
@ -165,6 +164,9 @@ export class ChapterDetailComponent implements OnInit {
hasReadingProgress = false;
userReviews: Array<UserReview> = [];
plusReviews: Array<UserReview> = [];
rating: number = 0;
hasBeenRated: boolean = false;
weblinks: Array<string> = [];
activeTabId = TabID.Details;
/**
@ -233,7 +235,7 @@ export class ChapterDetailComponent implements OnInit {
series: this.seriesService.getSeries(this.seriesId),
chapter: this.chapterService.getChapterMetadata(this.chapterId),
libraryType: this.libraryService.getLibraryType(this.libraryId),
reviews: this.chapterService.chapterReviews(this.chapterId),
chapterDetail: this.chapterService.chapterDetailPlus(this.seriesId, this.chapterId),
}).subscribe(results => {
if (results.chapter === null) {
@ -245,8 +247,10 @@ export class ChapterDetailComponent implements OnInit {
this.chapter = results.chapter;
this.weblinks = this.chapter.webLinks.split(',');
this.libraryType = results.libraryType;
this.userReviews = results.reviews.filter(r => !r.isExternal);
this.plusReviews = results.reviews.filter(r => r.isExternal);
this.userReviews = results.chapterDetail.reviews.filter(r => !r.isExternal);
this.plusReviews = results.chapterDetail.reviews.filter(r => r.isExternal);
this.rating = results.chapterDetail.rating;
this.hasBeenRated = results.chapterDetail.hasBeenRated;
this.themeService.setColorScape(this.chapter.primaryColor, this.chapter.secondaryColor);
@ -386,10 +390,6 @@ export class ChapterDetailComponent implements OnInit {
}
}
userRating() {
return this.userReviews.find(r => r.username == this.user?.username && !r.isExternal)
}
protected readonly LibraryType = LibraryType;
protected readonly encodeURIComponent = encodeURIComponent;
}

View file

@ -31,11 +31,10 @@
@if (libraryType !== null && series && volume.chapters.length === 1) {
<div class="mt-2 mb-2">
@let rating = userRating();
<app-external-rating [seriesId]="series.id"
[ratings]="[]"
[userRating]="rating?.rating || 0"
[hasUserRated]="rating !== undefined && rating.hasBeenRated"
[userRating]="rating"
[hasUserRated]="hasBeenRated"
[libraryType]="libraryType"
[chapterId]="volume.chapters[0].id"
/>

View file

@ -83,6 +83,7 @@ import {ReviewsComponent} from "../_single-module/reviews/reviews.component";
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
import {User} from "../_models/user";
import {ReviewService} from "../_services/review.service";
import {ChapterService} from "../_services/chapter.service";
enum TabID {
@ -183,7 +184,7 @@ export class VolumeDetailComponent implements OnInit {
private readonly readingListService = inject(ReadingListService);
private readonly messageHub = inject(MessageHubService);
private readonly location = inject(Location);
private readonly reviewService = inject(ReviewService);
private readonly chapterService = inject(ChapterService);
protected readonly AgeRating = AgeRating;
@ -204,8 +205,13 @@ export class VolumeDetailComponent implements OnInit {
libraryType: LibraryType | null = null;
activeTabId = TabID.Chapters;
readingLists: ReadingList[] = [];
// Only populated if the volume has exactly one chapter
userReviews: Array<UserReview> = [];
plusReviews: Array<UserReview> = [];
rating: number = 0;
hasBeenRated: boolean = false;
mobileSeriesImgBackground: string | undefined;
downloadInProgress: boolean = false;
@ -405,9 +411,11 @@ export class VolumeDetailComponent implements OnInit {
this.libraryType = results.libraryType;
if (this.volume.chapters.length === 1) {
this.reviewService.getReviews(this.seriesId, this.volume.chapters[0].id).subscribe(reviews => {
this.userReviews = reviews.filter(r => !r.isExternal);
this.plusReviews = reviews.filter(r => r.isExternal);
this.chapterService.chapterDetailPlus(this.seriesId, this.volume.chapters[0].id).subscribe(detail => {
this.userReviews = detail.reviews.filter(r => !r.isExternal);
this.plusReviews = detail.reviews.filter(r => r.isExternal);
this.rating = detail.rating;
this.hasBeenRated = detail.hasBeenRated;
});
}
@ -692,9 +700,5 @@ export class VolumeDetailComponent implements OnInit {
}
}
userRating() {
return this.userReviews.find(r => r.username === this.user?.username && !r.isExternal);
}
protected readonly encodeURIComponent = encodeURIComponent;
}