Chapter/Issue level Reviews and Ratings (#3778)

Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
Fesaa 2025-04-29 18:53:24 +02:00 committed by GitHub
parent 3b8997e46e
commit 4f7625ea77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 5097 additions and 497 deletions

View file

@ -8,6 +8,10 @@ public static class EasyCacheProfiles
public const string RevokedJwt = "revokedJWT";
public const string Favicon = "favicon";
/// <summary>
/// Images for Publishers
/// </summary>
public const string Publisher = "publisherImages";
/// <summary>
/// If a user's license is valid
/// </summary>
public const string License = "license";

View file

@ -6,6 +6,7 @@ using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Person;
@ -14,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;
@ -27,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>
@ -391,6 +397,39 @@ public class ChapterController : BaseApiController
return Ok();
}
/// <summary>
/// Returns Ratings and Reviews for an individual Chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-detail-plus")]
public async Task<ActionResult<ChapterDetailPlusDto>> ChapterDetailPlus([FromQuery] int chapterId)
{
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.GetUserChapterRatingAsync(User.GetUserId(), chapterId);
if (ownRating != null)
{
ret.Rating = ownRating.Rating;
ret.HasBeenRated = ownRating.HasBeenRated;
}
var externalReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReviews(chapterId);
if (externalReviews.Count > 0)
{
userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(externalReviews));
}
ret.Reviews = userReviews;
ret.Ratings = await _unitOfWork.ChapterRepository.GetExternalChapterRatings(chapterId);
return Ok(ret);
}
}

View file

@ -1,15 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using EasyCaching.Core;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
@ -21,21 +18,85 @@ namespace API.Controllers;
public class RatingController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IRatingService _ratingService;
private readonly ILocalizationService _localizationService;
public RatingController(IUnitOfWork unitOfWork)
public RatingController(IUnitOfWork unitOfWork, IRatingService ratingService, ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_ratingService = ratingService;
_localizationService = localizationService;
}
[HttpGet("overall")]
public async Task<ActionResult<RatingDto>> GetOverallRating(int seriesId)
/// <summary>
/// Update the users' rating of the given series
/// </summary>
/// <param name="updateRating"></param>
/// <returns></returns>
/// <exception cref="UnauthorizedAccessException"></exception>
[HttpPost("series")]
public async Task<ActionResult> UpdateSeriesRating(UpdateRatingDto updateRating)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings);
if (user == null) throw new UnauthorizedAccessException();
if (await _ratingService.UpdateSeriesRating(user, updateRating))
{
return Ok();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
/// <summary>
/// Update the users' rating of the given chapter
/// </summary>
/// <param name="updateRating">chapterId must be set</param>
/// <returns></returns>
/// <exception cref="UnauthorizedAccessException"></exception>
[HttpPost("chapter")]
public async Task<ActionResult> UpdateChapterRating(UpdateRatingDto updateRating)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings);
if (user == null) throw new UnauthorizedAccessException();
if (await _ratingService.UpdateChapterRating(user, updateRating))
{
return Ok();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
/// <summary>
/// Overall rating from all Kavita users for a given Series
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("overall-series")]
public async Task<ActionResult<RatingDto>> GetOverallSeriesRating(int seriesId)
{
return Ok(new RatingDto()
{
Provider = ScrobbleProvider.Kavita,
AverageScore = await _unitOfWork.SeriesRepository.GetAverageUserRating(seriesId, User.GetUserId()),
FavoriteCount = 0
FavoriteCount = 0,
});
}
/// <summary>
/// Overall rating from all Kavita users for a given Chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("overall-chapter")]
public async Task<ActionResult<RatingDto>> GetOverallChapterRating(int chapterId)
{
return Ok(new RatingDto()
{
Provider = ScrobbleProvider.Kavita,
AverageScore = await _unitOfWork.ChapterRepository.GetAverageUserRating(chapterId, User.GetUserId()),
FavoriteCount = 0,
});
}
}

View file

@ -1,8 +1,11 @@
using System.Linq;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Builders;
using API.Services.Plus;
@ -30,17 +33,17 @@ public class ReviewController : BaseApiController
/// <summary>
/// Updates the review for a given series
/// Updates the user's review for a given series
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost]
public async Task<ActionResult<UserReviewDto>> UpdateReview(UpdateUserReviewDto dto)
[HttpPost("series")]
public async Task<ActionResult<UserReviewDto>> UpdateSeriesReview(UpdateUserReviewDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings);
if (user == null) return Unauthorized();
var ratingBuilder = new RatingBuilder(user.Ratings.FirstOrDefault(r => r.SeriesId == dto.SeriesId));
var ratingBuilder = new RatingBuilder(await _unitOfWork.UserRepository.GetUserRatingAsync(dto.SeriesId, user.Id));
var rating = ratingBuilder
.WithBody(dto.Body)
@ -52,22 +55,58 @@ public class ReviewController : BaseApiController
{
user.Ratings.Add(rating);
}
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
BackgroundJob.Enqueue(() =>
_scrobblingService.ScrobbleReviewUpdate(user.Id, dto.SeriesId, string.Empty, dto.Body));
return Ok(_mapper.Map<UserReviewDto>(rating));
}
/// <summary>
/// Update the user's review for a given chapter
/// </summary>
/// <param name="dto">chapterId must be set</param>
/// <returns></returns>
[HttpPost("chapter")]
public async Task<ActionResult<UserReviewDto>> UpdateChapterReview(UpdateUserReviewDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings);
if (user == null) return Unauthorized();
if (dto.ChapterId == null) return BadRequest();
int chapterId = dto.ChapterId.Value;
var ratingBuilder = new ChapterRatingBuilder(await _unitOfWork.UserRepository.GetUserChapterRatingAsync(user.Id, chapterId));
var rating = ratingBuilder
.WithBody(dto.Body)
.WithSeriesId(dto.SeriesId)
.WithChapterId(chapterId)
.Build();
if (rating.Id == 0)
{
user.ChapterRatings.Add(rating);
}
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
return Ok(_mapper.Map<UserReviewDto>(rating));
}
/// <summary>
/// Deletes the user's review for the given series
/// </summary>
/// <returns></returns>
[HttpDelete]
public async Task<ActionResult> DeleteReview(int seriesId)
[HttpDelete("series")]
public async Task<ActionResult> DeleteSeriesReview([FromQuery] int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings);
if (user == null) return Unauthorized();
@ -80,4 +119,23 @@ public class ReviewController : BaseApiController
return Ok();
}
/// <summary>
/// Deletes the user's review for the given chapter
/// </summary>
/// <returns></returns>
[HttpDelete("chapter")]
public async Task<ActionResult> DeleteChapterReview([FromQuery] int chapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings);
if (user == null) return Unauthorized();
user.ChapterRatings = user.ChapterRatings.Where(r => r.ChapterId != chapterId).ToList();
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
return Ok();
}
}

View file

@ -191,21 +191,6 @@ public class SeriesController : BaseApiController
return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId));
}
/// <summary>
/// Update the user rating for the given series
/// </summary>
/// <param name="updateSeriesRatingDto"></param>
/// <returns></returns>
[HttpPost("update-rating")]
public async Task<ActionResult> UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings);
if (!await _seriesService.UpdateRating(user!, updateSeriesRatingDto))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
return Ok();
}
/// <summary>
/// Updates the Series
/// </summary>

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 IList<UserReviewDto> Reviews { get; set; } = [];
public IList<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,10 +1,10 @@
using System.ComponentModel.DataAnnotations;

namespace API.DTOs.SeriesDetail;
#nullable enable
public class UpdateUserReviewDto
{
public int SeriesId { get; set; }
public int? ChapterId { get; set; }
public string Body { get; set; }
}

View file

@ -1,4 +1,6 @@
using API.Services.Plus;
using API.Entities;
using API.Entities.Enums;
using API.Services.Plus;
namespace API.DTOs.SeriesDetail;
#nullable enable
@ -26,6 +28,7 @@ public class UserReviewDto
/// The series this is for
/// </summary>
public int SeriesId { get; set; }
public int? ChapterId { get; set; }
/// <summary>
/// The library this series belongs in
/// </summary>
@ -54,4 +57,8 @@ public class UserReviewDto
/// If this review is External, which Provider did it come from
/// </summary>
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita;
/// <summary>
/// Source of the Rating
/// </summary>
public RatingAuthority Authority { get; set; } = RatingAuthority.User;
}

View file

@ -1,7 +1,8 @@
namespace API.DTOs;
public class UpdateSeriesRatingDto
public class UpdateRatingDto
{
public int SeriesId { get; init; }
public int? ChapterId { get; init; }
public float UserRating { get; init; }
}

View file

@ -78,6 +78,7 @@ 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<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,165 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ChapterRatingAndReviews : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Authority",
table: "ExternalReview",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "ChapterId",
table: "ExternalReview",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Authority",
table: "ExternalRating",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "ChapterId",
table: "ExternalRating",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<float>(
name: "AverageExternalRating",
table: "Chapter",
type: "REAL",
nullable: false,
defaultValue: 0f);
migrationBuilder.CreateTable(
name: "AppUserChapterRating",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Rating = table.Column<float>(type: "REAL", nullable: false),
HasBeenRated = table.Column<bool>(type: "INTEGER", nullable: false),
Review = table.Column<string>(type: "TEXT", nullable: true),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserChapterRating", x => x.Id);
table.ForeignKey(
name: "FK_AppUserChapterRating_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppUserChapterRating_Chapter_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapter",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppUserChapterRating_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ExternalReview_ChapterId",
table: "ExternalReview",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_ExternalRating_ChapterId",
table: "ExternalRating",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_AppUserChapterRating_AppUserId",
table: "AppUserChapterRating",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserChapterRating_ChapterId",
table: "AppUserChapterRating",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_AppUserChapterRating_SeriesId",
table: "AppUserChapterRating",
column: "SeriesId");
migrationBuilder.AddForeignKey(
name: "FK_ExternalRating_Chapter_ChapterId",
table: "ExternalRating",
column: "ChapterId",
principalTable: "Chapter",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_ExternalReview_Chapter_ChapterId",
table: "ExternalReview",
column: "ChapterId",
principalTable: "Chapter",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ExternalRating_Chapter_ChapterId",
table: "ExternalRating");
migrationBuilder.DropForeignKey(
name: "FK_ExternalReview_Chapter_ChapterId",
table: "ExternalReview");
migrationBuilder.DropTable(
name: "AppUserChapterRating");
migrationBuilder.DropIndex(
name: "IX_ExternalReview_ChapterId",
table: "ExternalReview");
migrationBuilder.DropIndex(
name: "IX_ExternalRating_ChapterId",
table: "ExternalRating");
migrationBuilder.DropColumn(
name: "Authority",
table: "ExternalReview");
migrationBuilder.DropColumn(
name: "ChapterId",
table: "ExternalReview");
migrationBuilder.DropColumn(
name: "Authority",
table: "ExternalRating");
migrationBuilder.DropColumn(
name: "ChapterId",
table: "ExternalRating");
migrationBuilder.DropColumn(
name: "AverageExternalRating",
table: "Chapter");
}
}
}

View file

@ -195,6 +195,41 @@ namespace API.Data.Migrations
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserChapterRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<bool>("HasBeenRated")
.HasColumnType("INTEGER");
b.Property<float>("Rating")
.HasColumnType("REAL");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("ChapterId");
b.HasIndex("SeriesId");
b.ToTable("AppUserChapterRating");
});
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
{
b.Property<int>("Id")
@ -752,6 +787,9 @@ namespace API.Data.Migrations
b.Property<string>("AlternateSeries")
.HasColumnType("TEXT");
b.Property<float>("AverageExternalRating")
.HasColumnType("REAL");
b.Property<float>("AvgHoursToRead")
.HasColumnType("REAL");
@ -1316,9 +1354,15 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Authority")
.HasColumnType("INTEGER");
b.Property<int>("AverageScore")
.HasColumnType("INTEGER");
b.Property<int?>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
@ -1333,6 +1377,8 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("ExternalRating");
});
@ -1379,12 +1425,18 @@ namespace API.Data.Migrations
.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");
@ -1414,6 +1466,8 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("ExternalReview");
});
@ -2618,6 +2672,33 @@ namespace API.Data.Migrations
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserChapterRating", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("ChapterRatings")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("Ratings")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", "Series")
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
b.Navigation("Chapter");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -2905,6 +2986,20 @@ namespace API.Data.Migrations
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b =>
{
b.HasOne("API.Entities.Chapter", null)
.WithMany("ExternalRatings")
.HasForeignKey("ChapterId");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b =>
{
b.HasOne("API.Entities.Chapter", null)
.WithMany("ExternalReviews")
.HasForeignKey("ChapterId");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
@ -3332,6 +3427,8 @@ namespace API.Data.Migrations
{
b.Navigation("Bookmarks");
b.Navigation("ChapterRatings");
b.Navigation("Collections");
b.Navigation("DashboardStreams");
@ -3363,10 +3460,16 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("ExternalRatings");
b.Navigation("ExternalReviews");
b.Navigation("Files");
b.Navigation("People");
b.Navigation("Ratings");
b.Navigation("UserProgress");
});

View file

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using API.DTOs;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
@ -24,7 +25,8 @@ public enum ChapterIncludes
Files = 4,
People = 8,
Genres = 16,
Tags = 32
Tags = 32,
ExternalReviews = 1 << 6,
}
public interface IChapterRepository
@ -48,6 +50,9 @@ public interface IChapterRepository
Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter);
IEnumerable<Chapter> GetChaptersForSeries(int seriesId);
Task<IList<Chapter>> GetAllChaptersForSeries(int seriesId);
Task<int> GetAverageUserRating(int chapterId, int userId);
Task<IList<UserReviewDto>> GetExternalChapterReviews(int chapterId);
Task<IList<RatingDto>> GetExternalChapterRatings(int chapterId);
}
public class ChapterRepository : IChapterRepository
{
@ -310,4 +315,39 @@ public class ChapterRepository : IChapterRepository
.ThenInclude(cp => cp.Person)
.ToListAsync();
}
public async Task<int> GetAverageUserRating(int chapterId, int userId)
{
// If there is 0 or 1 rating and that rating is you, return 0 back
var countOfRatingsThatAreUser = await _context.AppUserChapterRating
.Where(r => r.ChapterId == chapterId && r.HasBeenRated)
.CountAsync(u => u.AppUserId == userId);
if (countOfRatingsThatAreUser == 1)
{
return 0;
}
var avg = (await _context.AppUserChapterRating
.Where(r => r.ChapterId == chapterId && r.HasBeenRated)
.AverageAsync(r => (int?) r.Rating));
return avg.HasValue ? (int) (avg.Value * 20) : 0;
}
public async Task<IList<UserReviewDto>> GetExternalChapterReviews(int chapterId)
{
return await _context.Chapter
.Where(c => c.Id == chapterId)
.SelectMany(c => c.ExternalReviews)
// Don't use ProjectTo, it fails to map int to float (??)
.Select(r => _mapper.Map<UserReviewDto>(r))
.ToListAsync();
}
public async Task<IList<RatingDto>> GetExternalChapterRatings(int chapterId)
{
return await _context.Chapter
.Where(c => c.Id == chapterId)
.SelectMany(c => c.ExternalRatings)
.ProjectTo<RatingDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
}

View file

@ -42,7 +42,8 @@ public enum AppUserIncludes
DashboardStreams = 2048,
SideNavStreams = 4096,
ExternalSources = 8192,
Collections = 16384 // 2^14
Collections = 16384, // 2^14
ChapterRatings = 1 << 15,
}
public interface IUserRepository
@ -65,7 +66,9 @@ public interface IUserRepository
Task<bool> IsUserAdminAsync(AppUser? user);
Task<IList<string>> GetRoles(int userId);
Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId);
Task<AppUserChapterRating?> GetUserChapterRatingAsync(int userId, int chapterId);
Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId);
Task<IList<UserReviewDto>> GetUserRatingDtosForChapterAsync(int chapterId, int userId);
Task<AppUserPreferences?> GetPreferencesAsync(string username);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
@ -587,7 +590,14 @@ public class UserRepository : IUserRepository
{
return await _context.AppUserRating
.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
.SingleOrDefaultAsync();
.FirstOrDefaultAsync();
}
public async Task<AppUserChapterRating?> GetUserChapterRatingAsync(int userId, int chapterId)
{
return await _context.AppUserChapterRating
.Where(r => r.AppUserId == userId && r.ChapterId == chapterId)
.FirstOrDefaultAsync();
}
public async Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId)
@ -603,6 +613,19 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IList<UserReviewDto>> GetUserRatingDtosForChapterAsync(int chapterId, int userId)
{
return await _context.AppUserChapterRating
.Include(r => r.AppUser)
.Where(r => r.ChapterId == chapterId)
.Where(r => r.AppUser.UserPreferences.ShareReviews || r.AppUserId == userId)
.OrderBy(r => r.AppUserId == userId)
.ThenBy(r => r.Rating)
.AsSplitQuery()
.ProjectTo<UserReviewDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<AppUserPreferences?> GetPreferencesAsync(string username)
{
return await _context.AppUserPreferences

View file

@ -19,6 +19,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
public ICollection<AppUserRole> UserRoles { get; set; } = null!;
public ICollection<AppUserProgress> Progresses { get; set; } = null!;
public ICollection<AppUserRating> Ratings { get; set; } = null!;
public ICollection<AppUserChapterRating> ChapterRatings { get; set; } = null!;
public AppUserPreferences UserPreferences { get; set; } = null!;
/// <summary>
/// Bookmarks associated with this User

View file

@ -0,0 +1,30 @@
namespace API.Entities;
public class AppUserChapterRating
{
public int Id { get; set; }
/// <summary>
/// A number between 0-5.0 that represents how good a series is.
/// </summary>
public float Rating { get; set; }
/// <summary>
/// If the rating has been explicitly set. Otherwise, the 0.0 rating should be ignored as it's not rated
/// </summary>
public bool HasBeenRated { get; set; }
/// <summary>
/// A short summary the user can write when giving their review.
/// </summary>
public string? Review { get; set; }
/// <summary>
/// An optional tagline for the review
/// </summary>
public int SeriesId { get; set; }
public Series Series { get; set; } = null!;
public int ChapterId { get; set; }
public Chapter Chapter { get; set; } = null!;
// Relationships
public int AppUserId { get; set; }
public AppUser AppUser { get; set; } = null!;
}

View file

@ -26,7 +26,6 @@ public class AppUserRating
public int SeriesId { get; set; }
public Series Series { get; set; } = null!;
// Relationships
public int AppUserId { get; set; }
public AppUser AppUser { get; set; } = null!;

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;
@ -125,6 +126,11 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
public string WebLinks { get; set; } = string.Empty;
public string ISBN { get; set; } = string.Empty;
/// <summary>
/// (Kavita+) Average rating from Kavita+ metadata
/// </summary>
public float AverageExternalRating { get; set; } = 0f;
#region Locks
public bool AgeRatingLocked { get; set; }
@ -160,6 +166,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
/// </summary>
public ICollection<Genre> Genres { get; set; } = new List<Genre>();
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
public ICollection<AppUserChapterRating> Ratings { get; set; } = [];
public ICollection<AppUserProgress> UserProgress { get; set; }
@ -168,6 +175,9 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
public Volume Volume { get; set; } = null!;
public int VolumeId { get; set; }
public ICollection<ExternalReview> ExternalReviews { get; set; } = [];
public ICollection<ExternalRating> ExternalRatings { get; set; } = null!;
public void UpdateFrom(ParserInfo info)
{
Files ??= new List<MangaFile>();
@ -192,8 +202,6 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
/// <returns></returns>
public string GetNumberTitle()
{
// BUG: TODO: On non-english locales, for floats, the range will be 20,5 but the NumberTitle will return 20.5
// Have I fixed this with TryParse CultureInvariant
try
{
if (MinNumber.Is(MaxNumber))

View file

@ -0,0 +1,17 @@
using System.ComponentModel;
namespace API.Entities.Enums;
public enum RatingAuthority
{
/// <summary>
/// Rating was from a User (internet or local)
/// </summary>
[Description("User")]
User = 0,
/// <summary>
/// Rating was from Professional Critics
/// </summary>
[Description("Critic")]
Critic = 1,
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using API.Entities.Enums;
using API.Services.Plus;
namespace API.Entities.Metadata;
@ -10,8 +11,13 @@ public class ExternalRating
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; }
public int SeriesId { get; set; }
/// <summary>
/// This can be null when for a series-rating
/// </summary>
public int? ChapterId { get; set; }
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using API.Entities.Enums;
using API.Services.Plus;
namespace API.Entities.Metadata;
@ -20,6 +21,7 @@ public class ExternalReview
/// </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
@ -37,6 +39,7 @@ public class ExternalReview
public int SeriesId { get; set; }
public int? ChapterId { get; set; }
// Relationships
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;

View file

@ -52,6 +52,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IMediaErrorService, MediaErrorService>();
services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IStreamService, StreamService>();
services.AddScoped<IRatingService, RatingService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IProcessSeries, ProcessSeries>();
@ -84,6 +85,7 @@ public static class ApplicationServiceExtensions
services.AddEasyCaching(options =>
{
options.UseInMemory(EasyCacheProfiles.Favicon);
options.UseInMemory(EasyCacheProfiles.Publisher);
options.UseInMemory(EasyCacheProfiles.Library);
options.UseInMemory(EasyCacheProfiles.RevokedJwt);
options.UseInMemory(EasyCacheProfiles.LocaleOptions);

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;
@ -72,6 +73,12 @@ public static class IncludesExtensions
.Include(c => c.Tags);
}
if (includes.HasFlag(ChapterIncludes.ExternalReviews))
{
queryable = queryable
.Include(c => c.ExternalReviews);
}
return queryable.AsSplitQuery();
}
@ -253,6 +260,11 @@ public static class IncludesExtensions
.ThenInclude(c => c.Items);
}
if (includeFlags.HasFlag(AppUserIncludes.ChapterRatings))
{
query = query.Include(u => u.ChapterRatings);
}
return query.AsSplitQuery();
}

View file

@ -97,6 +97,16 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.Username,
opt =>
opt.MapFrom(src => src.AppUser.UserName));
CreateMap<AppUserChapterRating, UserReviewDto>()
.ForMember(dest => dest.LibraryId,
opt =>
opt.MapFrom(src => src.Series.LibraryId))
.ForMember(dest => dest.Body,
opt =>
opt.MapFrom(src => src.Review))
.ForMember(dest => dest.Username,
opt =>
opt.MapFrom(src => src.AppUser.UserName));
CreateMap<AppUserProgress, ProgressDto>()
.ForMember(dest => dest.PageNum,

View file

@ -0,0 +1,40 @@
#nullable enable
using System;
using API.Entities;
namespace API.Helpers.Builders;
public class ChapterRatingBuilder : IEntityBuilder<AppUserChapterRating>
{
private readonly AppUserChapterRating _rating;
public AppUserChapterRating Build() => _rating;
public ChapterRatingBuilder(AppUserChapterRating? rating = null)
{
_rating = rating ?? new AppUserChapterRating();
}
public ChapterRatingBuilder WithSeriesId(int seriesId)
{
_rating.SeriesId = seriesId;
return this;
}
public ChapterRatingBuilder WithChapterId(int chapterId)
{
_rating.ChapterId = chapterId;
return this;
}
public ChapterRatingBuilder WithRating(int rating)
{
_rating.Rating = Math.Clamp(rating, 0, 5);
return this;
}
public ChapterRatingBuilder WithBody(string body)
{
_rating.Review = body;
return this;
}
}

View file

@ -1085,15 +1085,97 @@ public class ExternalMetadataService : IExternalMetadataService
madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification;
madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification;
madeModification = UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification;
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
}
return madeModification;
}
private bool UpdateExternalChapterMetadata(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata)
{
if (!settings.Enabled) return false;
if (metadata.UserReviews.Count == 0 && metadata.CriticReviews.Count == 0)
{
return false;
}
var madeModification = false;
#region Review
_unitOfWork.ExternalSeriesMetadataRepository.Remove(chapter.ExternalReviews);
List<ExternalReview> externalReviews = [];
externalReviews.AddRange(metadata.CriticReviews
.Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body))
.Select(r =>
{
var review = _mapper.Map<ExternalReview>(r);
review.ChapterId = chapter.Id;
review.Authority = RatingAuthority.Critic;
CleanCbrReview(ref review);
return review;
}));
externalReviews.AddRange(metadata.UserReviews
.Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body))
.Select(r =>
{
var review = _mapper.Map<ExternalReview>(r);
review.ChapterId = chapter.Id;
review.Authority = RatingAuthority.User;
CleanCbrReview(ref review);
return review;
}));
chapter.ExternalReviews = externalReviews;
madeModification = externalReviews.Count > 0;
_logger.LogDebug("Added {Count} reviews for chapter {ChapterId}", externalReviews.Count, chapter.Id);
#endregion
#region Rating
var averageCriticRating = metadata.CriticReviews.Average(r => r.Rating);
var averageUserRating = metadata.UserReviews.Average(r => r.Rating);
_unitOfWork.ExternalSeriesMetadataRepository.Remove(chapter.ExternalRatings);
chapter.ExternalRatings =
[
new ExternalRating
{
AverageScore = (int) averageUserRating,
Provider = ScrobbleProvider.Cbr,
Authority = RatingAuthority.User,
ProviderUrl = metadata.IssueUrl,
},
new ExternalRating
{
AverageScore = (int) averageCriticRating,
Provider = ScrobbleProvider.Cbr,
Authority = RatingAuthority.Critic,
ProviderUrl = metadata.IssueUrl,
},
];
chapter.AverageExternalRating = averageUserRating;
madeModification = averageUserRating > 0f || averageCriticRating > 0f || madeModification;
#endregion
return madeModification;
}
private static void CleanCbrReview(ref ExternalReview review)
{
// CBR has Read Full Review which links to site, but we already have that
review.Body = review.Body.Replace("Read Full Review", string.Empty).TrimEnd();
review.RawBody = review.RawBody.Replace("Read Full Review", string.Empty).TrimEnd();
review.BodyJustText = review.BodyJustText.Replace("Read Full Review", string.Empty).TrimEnd();
}
private static bool UpdateChapterSummary(Chapter chapter, MetadataSettingsDto settings, string? summary)
{

View file

@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Entities;
using API.Services.Plus;
using Hangfire;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface IRatingService
{
/// <summary>
/// Updates the users' rating for a given series
/// </summary>
/// <param name="user">Should include ratings</param>
/// <param name="updateRatingDto"></param>
/// <returns></returns>
Task<bool> UpdateSeriesRating(AppUser user, UpdateRatingDto updateRatingDto);
/// <summary>
/// Updates the users' rating for a given chapter
/// </summary>
/// <param name="user">Should include ratings</param>
/// <param name="updateRatingDto">chapterId must be set</param>
/// <returns></returns>
Task<bool> UpdateChapterRating(AppUser user, UpdateRatingDto updateRatingDto);
}
public class RatingService: IRatingService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IScrobblingService _scrobblingService;
private readonly ILogger<RatingService> _logger;
public RatingService(IUnitOfWork unitOfWork, IScrobblingService scrobblingService, ILogger<RatingService> logger)
{
_unitOfWork = unitOfWork;
_scrobblingService = scrobblingService;
_logger = logger;
}
public async Task<bool> UpdateSeriesRating(AppUser user, UpdateRatingDto updateRatingDto)
{
var userRating =
await _unitOfWork.UserRepository.GetUserRatingAsync(updateRatingDto.SeriesId, user.Id) ??
new AppUserRating();
try
{
userRating.Rating = Math.Clamp(updateRatingDto.UserRating, 0f, 5f);
userRating.HasBeenRated = true;
userRating.SeriesId = updateRatingDto.SeriesId;
if (userRating.Id == 0)
{
user.Ratings ??= new List<AppUserRating>();
user.Ratings.Add(userRating);
}
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
{
BackgroundJob.Enqueue(() =>
_scrobblingService.ScrobbleRatingUpdate(user.Id, updateRatingDto.SeriesId,
userRating.Rating));
return true;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception saving rating");
}
await _unitOfWork.RollbackAsync();
user.Ratings?.Remove(userRating);
return false;
}
public async Task<bool> UpdateChapterRating(AppUser user, UpdateRatingDto updateRatingDto)
{
if (updateRatingDto.ChapterId == null)
{
return false;
}
var userRating =
await _unitOfWork.UserRepository.GetUserChapterRatingAsync(user.Id, updateRatingDto.ChapterId.Value) ??
new AppUserChapterRating();
try
{
userRating.Rating = Math.Clamp(updateRatingDto.UserRating, 0f, 5f);
userRating.HasBeenRated = true;
userRating.SeriesId = updateRatingDto.SeriesId;
userRating.ChapterId = updateRatingDto.ChapterId.Value;
if (userRating.Id == 0)
{
user.ChapterRatings ??= new List<AppUserChapterRating>();
user.ChapterRatings.Add(userRating);
}
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception saving rating");
}
await _unitOfWork.RollbackAsync();
user.ChapterRatings?.Remove(userRating);
return false;
}
}

View file

@ -29,13 +29,11 @@ public interface ISeriesService
{
Task<SeriesDetailDto> GetSeriesDetail(int seriesId, int userId);
Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto);
Task<bool> UpdateRating(AppUser user, UpdateSeriesRatingDto updateSeriesRatingDto);
Task<bool> DeleteMultipleSeries(IList<int> seriesIds);
Task<bool> UpdateRelatedSeries(UpdateRelatedSeriesDto dto);
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
Task<string> FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true);
Task<string> FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true);
Task<string> FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle,
bool withHash);
Task<string> FormatChapterName(int userId, LibraryType libraryType, bool withHash = false);
@ -447,57 +445,6 @@ public class SeriesService : ISeriesService
}
/// <summary>
///
/// </summary>
/// <param name="user">User with Ratings includes</param>
/// <param name="updateSeriesRatingDto"></param>
/// <returns></returns>
public async Task<bool> UpdateRating(AppUser? user, UpdateSeriesRatingDto updateSeriesRatingDto)
{
if (user == null)
{
_logger.LogError("Cannot update rating of null user");
return false;
}
var userRating =
await _unitOfWork.UserRepository.GetUserRatingAsync(updateSeriesRatingDto.SeriesId, user.Id) ??
new AppUserRating();
try
{
userRating.Rating = Math.Clamp(updateSeriesRatingDto.UserRating, 0f, 5f);
userRating.HasBeenRated = true;
userRating.SeriesId = updateSeriesRatingDto.SeriesId;
if (userRating.Id == 0)
{
user.Ratings ??= new List<AppUserRating>();
user.Ratings.Add(userRating);
}
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
{
BackgroundJob.Enqueue(() =>
_scrobblingService.ScrobbleRatingUpdate(user.Id, updateSeriesRatingDto.SeriesId,
userRating.Rating));
return true;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception saving rating");
}
await _unitOfWork.RollbackAsync();
user.Ratings?.Remove(userRating);
return false;
}
public async Task<bool> DeleteMultipleSeries(IList<int> seriesIds)
{
try

View file

@ -45,6 +45,7 @@ public class CoverDbService : ICoverDbService
private readonly IImageService _imageService;
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private TimeSpan _cacheTime = TimeSpan.FromDays(10);
private const string NewHost = "https://www.kavitareader.com/CoversDB/";
@ -97,7 +98,7 @@ public class CoverDbService : ICoverDbService
throw new KavitaException($"Kavita has already tried to fetch from {sanitizedBaseUrl} and failed. Skipping duplicate check");
}
await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10));
await provider.SetAsync(baseUrl, string.Empty, _cacheTime);
if (FaviconUrlMapper.TryGetValue(baseUrl, out var value))
{
url = value;
@ -185,6 +186,17 @@ public class CoverDbService : ICoverDbService
{
try
{
// Sanitize user input
publisherName = publisherName.Replace(Environment.NewLine, string.Empty).Replace("\r", string.Empty).Replace("\n", string.Empty);
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Publisher);
var res = await provider.GetAsync<string>(publisherName);
if (res.HasValue)
{
_logger.LogInformation("Kavita has already tried to fetch Publisher: {PublisherName} and failed. Skipping duplicate check", publisherName);
throw new KavitaException($"Kavita has already tried to fetch Publisher: {publisherName} and failed. Skipping duplicate check");
}
await provider.SetAsync(publisherName, string.Empty, _cacheTime);
var publisherLink = await FallbackToKavitaReaderPublisher(publisherName);
if (string.IsNullOrEmpty(publisherLink))
{