Hooked up average rating for the issue, external ratings for individual issues (cbr only), and some polish.

Show Issues not Chapters for CBR matches.
This commit is contained in:
Joseph Milazzo 2025-04-29 10:05:01 -05:00
parent 6d4dfcda67
commit da99c97813
21 changed files with 231 additions and 40 deletions

View file

@ -423,6 +423,8 @@ public class ChapterController : BaseApiController
ret.Reviews = userReviews;
ret.Ratings = await _unitOfWork.ChapterRepository.GetExternalChapterRatings(chapterId);
return ret;
}

View file

@ -9,6 +9,6 @@ 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; }
public IList<UserReviewDto> Reviews { get; set; } = [];
public IList<RatingDto> Ratings { get; set; } = [];
}

View file

@ -57,5 +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

@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20250428180534_ChapterRating")]
partial class ChapterRating
[Migration("20250429150140_ChapterRatingAndReviews")]
partial class ChapterRatingAndReviews
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -790,6 +790,9 @@ namespace API.Data.Migrations
b.Property<string>("AlternateSeries")
.HasColumnType("TEXT");
b.Property<float>("AverageExternalRating")
.HasColumnType("REAL");
b.Property<float>("AvgHoursToRead")
.HasColumnType("REAL");
@ -1354,9 +1357,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");
@ -1371,6 +1380,8 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("ExternalRating");
});
@ -2978,6 +2989,13 @@ 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)
@ -3445,6 +3463,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("ExternalRatings");
b.Navigation("ExternalReviews");
b.Navigation("Files");

View file

@ -5,7 +5,7 @@
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ChapterRating : Migration
public partial class ChapterRatingAndReviews : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@ -23,6 +23,26 @@ namespace API.Data.Migrations
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
@ -64,6 +84,11 @@ namespace API.Data.Migrations
table: "ExternalReview",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_ExternalRating_ChapterId",
table: "ExternalRating",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_AppUserChapterRating_AppUserId",
table: "AppUserChapterRating",
@ -79,6 +104,13 @@ namespace API.Data.Migrations
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",
@ -90,6 +122,10 @@ namespace API.Data.Migrations
/// <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");
@ -101,6 +137,10 @@ namespace API.Data.Migrations
name: "IX_ExternalReview_ChapterId",
table: "ExternalReview");
migrationBuilder.DropIndex(
name: "IX_ExternalRating_ChapterId",
table: "ExternalRating");
migrationBuilder.DropColumn(
name: "Authority",
table: "ExternalReview");
@ -108,6 +148,18 @@ namespace API.Data.Migrations
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

@ -787,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");
@ -1351,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");
@ -1368,6 +1377,8 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("ExternalRating");
});
@ -2975,6 +2986,13 @@ 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)
@ -3442,6 +3460,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("ExternalRatings");
b.Navigation("ExternalReviews");
b.Navigation("Files");

View file

@ -52,6 +52,7 @@ public interface IChapterRepository
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
{
@ -340,4 +341,13 @@ public class ChapterRepository : IChapterRepository
.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

@ -126,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; }
@ -171,6 +176,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
public int VolumeId { get; set; }
public ICollection<ExternalReview> ExternalReviews { get; set; } = [];
public ICollection<ExternalRating> ExternalRatings { get; set; } = null!;
public void UpdateFrom(ParserInfo info)
{
@ -196,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

@ -1,7 +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

@ -1085,7 +1085,7 @@ public class ExternalMetadataService : IExternalMetadataService
madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification;
madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification;
madeModification = await UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification;
madeModification = UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification;
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
@ -1094,20 +1094,20 @@ public class ExternalMetadataService : IExternalMetadataService
return madeModification;
}
private async Task<bool> UpdateExternalChapterMetadata(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata)
private bool UpdateExternalChapterMetadata(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata)
{
if (!settings.Enabled) return false;
if (metadata.UserReviews.Count == 0 && metadata.CriticReviews.Count == 0)
{
_logger.LogDebug("No external reviews found for chapter {ChapterID}", chapter.Id);
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 =>
@ -1115,6 +1115,7 @@ public class ExternalMetadataService : IExternalMetadataService
var review = _mapper.Map<ExternalReview>(r);
review.ChapterId = chapter.Id;
review.Authority = RatingAuthority.Critic;
CleanCbrReview(ref review);
return review;
}));
externalReviews.AddRange(metadata.UserReviews
@ -1124,13 +1125,55 @@ public class ExternalMetadataService : IExternalMetadataService
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);
return true;
#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();
}

View file

@ -1,9 +1,15 @@
import {ScrobbleProvider} from "../_services/scrobbling.service";
export enum RatingAuthority {
User = 0,
Critic = 1,
}
export interface Rating {
averageScore: number;
meanScore: number;
favoriteCount: number;
provider: ScrobbleProvider;
providerUrl: string | undefined;
authority: RatingAuthority;
}

View file

@ -34,7 +34,11 @@
<div class="d-flex pt-3 justify-content-between">
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
@if (item.series.plusMediaFormat === PlusMediaFormat.Comic) {
<span class="me-1">{{t('issue-count', {num: item.series.chapters})}}</span>
} @else {
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
}
} @else {
<span class="me-1">{{t('releasing')}}</span>
}

View file

@ -14,6 +14,7 @@ import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {PlusMediaFormat} from "../../_models/series-detail/external-series-detail";
@Component({
selector: 'app-match-series-result-item',
@ -47,4 +48,5 @@ export class MatchSeriesResultItemComponent {
this.selected.emit(this.item);
}
protected readonly PlusMediaFormat = PlusMediaFormat;
}

View file

@ -1,9 +1,6 @@
import {ScrobbleProvider} from "../../_services/scrobbling.service";
import {RatingAuthority} from "../../_models/rating";
export enum RatingAuthority {
User = 0,
Critic = 1
}
export interface UserReview {
seriesId: number;

View file

@ -29,7 +29,7 @@
<div class="mt-2 mb-2">
<app-external-rating [seriesId]="series.id"
[ratings]="[]"
[ratings]="ratings"
[userRating]="rating"
[hasUserRated]="hasBeenRated"
[libraryType]="libraryType!"

View file

@ -1,23 +1,28 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
ElementRef,
inject,
OnInit,
ViewChild
} from '@angular/core';
import {AsyncPipe, DOCUMENT, NgStyle, NgClass, DatePipe, Location} from "@angular/common";
import {AsyncPipe, DatePipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {LoadingComponent} from "../shared/loading/loading.component";
import {
NgbDropdown,
NgbDropdownItem,
NgbDropdownMenu,
NgbDropdownToggle, NgbModal,
NgbNav, NgbNavChangeEvent,
NgbNavContent, NgbNavItem,
NgbNavLink, NgbNavOutlet,
NgbDropdownToggle,
NgbModal,
NgbNav,
NgbNavChangeEvent,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet,
NgbTooltip
} from "@ng-bootstrap/ng-bootstrap";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
@ -66,14 +71,10 @@ import {DefaultDatePipe} from "../_pipes/default-date.pipe";
import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../_models/default-modal-options";
import {UserReview} from "../_single-module/review-card/user-review";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
import {ReviewCardComponent} from "../_single-module/review-card/review-card.component";
import {User} from "../_models/user";
import {ReviewModalComponent} from "../_single-module/review-modal/review-modal.component";
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',
@ -149,6 +150,8 @@ export class ChapterDetailComponent implements OnInit {
protected readonly TabID = TabID;
protected readonly FilterField = FilterField;
protected readonly Breakpoint = Breakpoint;
protected readonly LibraryType = LibraryType;
protected readonly encodeURIComponent = encodeURIComponent;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -165,6 +168,7 @@ export class ChapterDetailComponent implements OnInit {
userReviews: Array<UserReview> = [];
plusReviews: Array<UserReview> = [];
rating: number = 0;
ratings: Array<Rating> = [];
hasBeenRated: boolean = false;
weblinks: Array<string> = [];
@ -251,6 +255,7 @@ export class ChapterDetailComponent implements OnInit {
this.plusReviews = results.chapterDetail.reviews.filter(r => r.isExternal);
this.rating = results.chapterDetail.rating;
this.hasBeenRated = results.chapterDetail.hasBeenRated;
this.ratings = results.chapterDetail.ratings;
this.themeService.setColorScape(this.chapter.primaryColor, this.chapter.secondaryColor);
@ -389,7 +394,4 @@ export class ChapterDetailComponent implements OnInit {
break;
}
}
protected readonly LibraryType = LibraryType;
protected readonly encodeURIComponent = encodeURIComponent;
}

View file

@ -17,7 +17,7 @@
@for (rating of ratings; track rating.provider + rating.averageScore) {
<div class="col-auto custom-col clickable" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
[popoverTitle]="rating.provider | scrobbleProviderName" popoverClass="sm-popover">
[popoverTitle]="(rating.provider | scrobbleProviderName) + getAuthorityTitle(rating)" popoverClass="sm-popover">
<span class="badge rounded-pill me-1">
<img class="me-1" [ngSrc]="rating.provider | providerImage:true" width="24" height="24" alt="" aria-hidden="true">
{{rating.averageScore}}%
@ -70,6 +70,7 @@
</div>
}
@if (rating.providerUrl) {
<a [href]="rating.providerUrl" target="_blank" rel="noreferrer nofollow">{{t('entry-label')}}</a>
}

View file

@ -8,8 +8,7 @@ import {
OnInit,
ViewEncapsulation
} from '@angular/core';
import {SeriesService} from "../../../_services/series.service";
import {Rating} from "../../../_models/rating";
import {Rating, RatingAuthority} from "../../../_models/rating";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {NgbModal, NgbPopover} from "@ng-bootstrap/ng-bootstrap";
import {LoadingComponent} from "../../../shared/loading/loading.component";
@ -18,13 +17,12 @@ import {NgxStarsModule} from "ngx-stars";
import {ThemeService} from "../../../_services/theme.service";
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {ImageComponent} from "../../../shared/image/image.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {ImageService} from "../../../_services/image.service";
import {AsyncPipe, NgOptimizedImage, NgTemplateOutlet} from "@angular/common";
import {RatingModalComponent} from "../rating-modal/rating-modal.component";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
import {ChapterService} from "../../../_services/chapter.service";
import {ReviewService} from "../../../_services/review.service";
@Component({
@ -86,4 +84,14 @@ export class ExternalRatingComponent implements OnInit {
this.cdRef.markForCheck();
});
}
getAuthorityTitle(rating: Rating) {
if (rating.authority === RatingAuthority.Critic) {
return ` (${translate('external-rating.critic')})`;
}
return '';
}
protected readonly RatingAuthority = RatingAuthority;
}

View file

@ -36,7 +36,6 @@
[mangaFormat]="series.format">
</app-metadata-detail-row>
<!-- Rating goes here (after I implement support for rating individual issues -->
<div class="mt-2 mb-2">
<app-external-rating [seriesId]="series.id"
[ratings]="ratings"

View file

@ -1011,6 +1011,7 @@
"match-series-result-item": {
"volume-count": "{{server-stats.volume-count}}",
"chapter-count": "{{common.chapter-count}}",
"issue-count": "{{common.issue-count}}",
"releasing": "Releasing",
"details": "View page",
"updating-metadata-status": "Updating Metadata"
@ -1048,7 +1049,8 @@
"entry-label": "See Details",
"kavita-tooltip": "Your Rating + Overall",
"kavita-rating-title": "Your Rating",
"close": "{{common.close}}"
"close": "{{common.close}}",
"critic": "Critic"
},
"badge-expander": {