Overall Ratings (#2129)

* Corrected tooltip for Cache

* Ensure we sync the DB to what's in appsettings.json for Cache key.

* Change the fingerprinting method for Windows installs exclusively to avoid churn due to how security updates are handled.

* Hooked up the ability to see where reviews are from via an icon on the review card, rather than having to click or know that MAL has "external Review" as title.

* Updated FAQ for Kavita+ to link directly to the FAQ

* Added the ability for all ratings on a series to be shown to the user.

Added favorite count on AL and MAL

* Cleaned up so the check for Kavita+ license doesn't seem like it's running when no license is registered.

* Tweaked the test instance buy link to test new product.
This commit is contained in:
Joe Milazzo 2023-07-14 14:12:03 -05:00 committed by GitHub
parent 34f828e750
commit 49daca943e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 231 additions and 56 deletions

View File

@ -16,5 +16,5 @@ public static class ResponseCacheProfiles
public const string Instant = "Instant"; public const string Instant = "Instant";
public const string Month = "Month"; public const string Month = "Month";
public const string LicenseCache = "LicenseCache"; public const string LicenseCache = "LicenseCache";
public const string Recommendation = "Recommendation"; public const string KavitaPlus = "KavitaPlus";
} }

View File

@ -65,7 +65,7 @@ public class LicenseController : BaseApiController
} }
/// <summary> /// <summary>
/// Updates server license. Returns true if updated and valid /// Updates server license
/// </summary> /// </summary>
/// <remarks>Caches the result</remarks> /// <remarks>Caches the result</remarks>
/// <returns></returns> /// <returns></returns>

View File

@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data;
using API.DTOs; using API.DTOs;
using API.Services.Plus; using API.Services.Plus;
using EasyCaching.Core; using EasyCaching.Core;
@ -18,15 +20,17 @@ public class RatingController : BaseApiController
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly IRatingService _ratingService; private readonly IRatingService _ratingService;
private readonly ILogger<RatingController> _logger; private readonly ILogger<RatingController> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IEasyCachingProvider _cacheProvider; private readonly IEasyCachingProvider _cacheProvider;
public const string CacheKey = "rating_"; public const string CacheKey = "rating_";
public RatingController(ILicenseService licenseService, IRatingService ratingService, public RatingController(ILicenseService licenseService, IRatingService ratingService,
ILogger<RatingController> logger, IEasyCachingProviderFactory cachingProviderFactory) ILogger<RatingController> logger, IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork)
{ {
_licenseService = licenseService; _licenseService = licenseService;
_ratingService = ratingService; _ratingService = ratingService;
_logger = logger; _logger = logger;
_unitOfWork = unitOfWork;
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings); _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
} }
@ -37,12 +41,13 @@ public class RatingController : BaseApiController
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <returns></returns> /// <returns></returns>
[HttpGet] [HttpGet]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Recommendation, VaryByQueryKeys = new []{"seriesId"})] [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
public async Task<ActionResult<IEnumerable<RatingDto>>> GetRating(int seriesId) public async Task<ActionResult<IEnumerable<RatingDto>>> GetRating(int seriesId)
{ {
if (!await _licenseService.HasActiveLicense()) if (!await _licenseService.HasActiveLicense())
{ {
return Ok(new List<RatingDto>()); return Ok(Enumerable.Empty<RatingDto>());
} }
var cacheKey = CacheKey + seriesId; var cacheKey = CacheKey + seriesId;
@ -56,6 +61,16 @@ public class RatingController : BaseApiController
await _cacheProvider.SetAsync(cacheKey, ratings, TimeSpan.FromHours(24)); await _cacheProvider.SetAsync(cacheKey, ratings, TimeSpan.FromHours(24));
_logger.LogDebug("Caching external rating for {Key}", cacheKey); _logger.LogDebug("Caching external rating for {Key}", cacheKey);
return Ok(ratings); return Ok(ratings);
}
[HttpGet("overall")]
public async Task<ActionResult<RatingDto>> GetOverallRating(int seriesId)
{
return Ok(new RatingDto()
{
Provider = ScrobbleProvider.Kavita,
AverageScore = await _unitOfWork.SeriesRepository.GetAverageUserRating(seriesId),
FavoriteCount = 0
});
} }
} }

View File

@ -39,7 +39,7 @@ public class RecommendedController : BaseApiController
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <returns></returns> /// <returns></returns>
[HttpGet("recommendations")] [HttpGet("recommendations")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Recommendation, VaryByQueryKeys = new []{"seriesId"})] [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
public async Task<ActionResult<RecommendationDto>> GetRecommendations(int seriesId) public async Task<ActionResult<RecommendationDto>> GetRecommendations(int seriesId)
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();

View File

@ -51,7 +51,7 @@ public class ReviewController : BaseApiController
/// </summary> /// </summary>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
[HttpGet] [HttpGet]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Recommendation, VaryByQueryKeys = new []{"seriesId"})] [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
public async Task<ActionResult<IEnumerable<UserReviewDto>>> GetReviews(int seriesId) public async Task<ActionResult<IEnumerable<UserReviewDto>>> GetReviews(int seriesId)
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();

View File

@ -241,7 +241,7 @@ public class ServerController : BaseApiController
/// <summary> /// <summary>
/// Bust Review and Recommendation Cache /// Bust Kavita+ Cache
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[Authorize("RequireAdminRole")] [Authorize("RequireAdminRole")]

View File

@ -1,4 +1,6 @@
namespace API.DTOs.SeriesDetail; using API.Services.Plus;
namespace API.DTOs.SeriesDetail;
/// <summary> /// <summary>
/// Represents a User Review for a given Series /// Represents a User Review for a given Series
@ -48,4 +50,9 @@ public class UserReviewDto
/// The main body with just text, for review preview /// The main body with just text, for review preview
/// </summary> /// </summary>
public string? BodyJustText { get; set; } public string? BodyJustText { get; set; }
/// <summary>
/// If this review is External, which Provider did it come from
/// </summary>
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita;
} }

View File

@ -135,8 +135,8 @@ public interface ISeriesRepository
Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync(); Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds); Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds);
Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat, bool customOnly = true); Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat, bool customOnly = true);
Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIdsForUser(int userId, IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl); Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIdsForUser(int userId, IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl);
Task<int> GetAverageUserRating(int seriesId);
} }
public class SeriesRepository : ISeriesRepository public class SeriesRepository : ISeriesRepository
@ -1658,6 +1658,18 @@ public class SeriesRepository : ISeriesRepository
.FirstOrDefaultAsync(); // Some users may have improperly configured libraries .FirstOrDefaultAsync(); // Some users may have improperly configured libraries
} }
/// <summary>
/// Returns the Average rating for all users within Kavita instance
/// </summary>
/// <param name="seriesId"></param>
public async Task<int> GetAverageUserRating(int seriesId)
{
var avg = (await _context.AppUserRating
.Where(r => r.SeriesId == seriesId)
.AverageAsync(r => (int?) r.Rating));
return avg.HasValue ? (int) avg.Value : 0;
}
public async Task<bool> IsSeriesInWantToRead(int userId, int seriesId) public async Task<bool> IsSeriesInWantToRead(int userId, int seriesId)
{ {
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();

View File

@ -133,6 +133,8 @@ public static class Seed
directoryService.CacheDirectory + string.Empty; directoryService.CacheDirectory + string.Empty;
context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value =
DirectoryService.BackupDirectory + string.Empty; DirectoryService.BackupDirectory + string.Empty;
context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheSize).Value =
Configuration.CacheSize + string.Empty;
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }

View File

@ -130,11 +130,13 @@ public class LicenseService : ILicenseService
{ {
try try
{ {
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
if (string.IsNullOrEmpty(license.Value)) return;
_logger.LogInformation("Validating Kavita+ License"); _logger.LogInformation("Validating Kavita+ License");
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
await provider.FlushAsync(); await provider.FlushAsync();
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
var isValid = await IsLicenseValid(license.Value); var isValid = await IsLicenseValid(license.Value);
await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout); await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);

View File

@ -22,9 +22,17 @@ using Microsoft.Extensions.Logging;
namespace API.Services.Plus; namespace API.Services.Plus;
/// <summary>
/// Misleading name but is the source of data (like a review coming from AniList)
/// </summary>
public enum ScrobbleProvider public enum ScrobbleProvider
{ {
AniList = 1 /// <summary>
/// For now, this means data comes from within this instance of Kavita
/// </summary>
Kavita = 0,
AniList = 1,
Mal = 2,
} }
public interface IScrobblingService public interface IScrobblingService

View File

@ -35,6 +35,7 @@ internal class MediaReviewDto
/// </summary> /// </summary>
public string RawBody { get; set; } public string RawBody { get; set; }
public string Username { get; set; } public string Username { get; set; }
public ScrobbleProvider Provider { get; set; }
} }
public interface IReviewService public interface IReviewService
@ -74,6 +75,7 @@ public class ReviewService : IReviewService
LibraryId = series.LibraryId, LibraryId = series.LibraryId,
SeriesId = series.Id, SeriesId = series.Id,
IsExternal = true, IsExternal = true,
Provider = r.Provider,
BodyJustText = GetCharacters(r.Body), BodyJustText = GetCharacters(r.Body),
ExternalUrl = r.SiteUrl ExternalUrl = r.SiteUrl
}); });

View File

@ -116,7 +116,7 @@ public class Startup
Location = ResponseCacheLocation.Client, Location = ResponseCacheLocation.Client,
NoStore = false NoStore = false
}); });
options.CacheProfiles.Add(ResponseCacheProfiles.Recommendation, options.CacheProfiles.Add(ResponseCacheProfiles.KavitaPlus,
new CacheProfile() new CacheProfile()
{ {
Duration = TimeSpan.FromDays(30).Seconds, Duration = TimeSpan.FromDays(30).Seconds,

View File

@ -51,9 +51,7 @@ public static class HashUtil
.AddComponent("ProcessorCount", new DeviceIdComponent($"{Environment.ProcessorCount}")) .AddComponent("ProcessorCount", new DeviceIdComponent($"{Environment.ProcessorCount}"))
.AddComponent("OSPlatform", new DeviceIdComponent($"{Environment.OSVersion.Platform}")) .AddComponent("OSPlatform", new DeviceIdComponent($"{Environment.OSVersion.Platform}"))
.OnWindows(windows => windows .OnWindows(windows => windows
.AddSystemUuid() .AddProcessorId())
.AddMotherboardSerialNumber()
.AddSystemDriveSerialNumber())
.OnLinux(linux => .OnLinux(linux =>
{ {
var osInfo = RunAndCapture("uname", "-a"); var osInfo = RunAndCapture("uname", "-a");

View File

@ -24,8 +24,9 @@ import {UtilityService} from "../shared/_services/utility.service";
import {ReadingList} from "../_models/reading-list"; import {ReadingList} from "../_models/reading-list";
export enum ScrobbleProvider { export enum ScrobbleProvider {
Kavita = 0,
AniList= 1, AniList= 1,
Mal = 2 Mal = 2,
} }
@Injectable({ @Injectable({

View File

@ -32,12 +32,12 @@ export class SeriesService {
paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>(); paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
constructor(private httpClient: HttpClient, private imageService: ImageService, constructor(private httpClient: HttpClient, private imageService: ImageService,
private utilityService: UtilityService, private filterUtilitySerivce: FilterUtilitiesService) { } private utilityService: UtilityService, private filterUtilityService: FilterUtilitiesService) { }
getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = this.filterUtilitySerivce.createSeriesFilter(filter); const data = this.filterUtilityService.createSeriesFilter(filter);
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe( return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe(
map((response: any) => { map((response: any) => {
@ -49,7 +49,7 @@ export class SeriesService {
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = this.filterUtilitySerivce.createSeriesFilter(filter); const data = this.filterUtilityService.createSeriesFilter(filter);
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
map((response: any) => { map((response: any) => {
@ -103,7 +103,7 @@ export class SeriesService {
} }
getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
const data = this.filterUtilitySerivce.createSeriesFilter(filter); const data = this.filterUtilityService.createSeriesFilter(filter);
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
@ -119,7 +119,7 @@ export class SeriesService {
} }
getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter): Observable<PaginatedResult<Series[]>> { getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter): Observable<PaginatedResult<Series[]>> {
const data = this.filterUtilitySerivce.createSeriesFilter(filter); const data = this.filterUtilityService.createSeriesFilter(filter);
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
@ -138,7 +138,7 @@ export class SeriesService {
} }
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
const data = this.filterUtilitySerivce.createSeriesFilter(filter); const data = this.filterUtilityService.createSeriesFilter(filter);
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
@ -223,4 +223,7 @@ export class SeriesService {
getRatings(seriesId: number) { getRatings(seriesId: number) {
return this.httpClient.get<Array<Rating>>(this.baseUrl + 'rating?seriesId=' + seriesId); return this.httpClient.get<Array<Rating>>(this.baseUrl + 'rating?seriesId=' + seriesId);
} }
getOverallRating(seriesId: number) {
return this.httpClient.get<Rating>(this.baseUrl + 'rating/overall?seriesId=' + seriesId);
}
} }

View File

@ -22,6 +22,12 @@
</div> </div>
<div class="card-footer bg-transparent text-muted"> <div class="card-footer bg-transparent text-muted">
<ng-container *ngIf="isMyReview; else normalReview">
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" title="This is your review"></i>
</ng-container>
<ng-template #normalReview>
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
</ng-template>
{{(isMyReview ? '' : review.username | defaultValue:'')}} {{(isMyReview ? '' : review.username | defaultValue:'')}}
<span style="float: right" *ngIf="review.isExternal">Rating {{review.score}}%</span> <span style="float: right" *ngIf="review.isExternal">Rating {{review.score}}%</span>
</div> </div>

View File

@ -8,6 +8,9 @@
z-index: 20; z-index: 20;
top: 38px; top: 38px;
left: 38px; left: 38px;
}
.fa-star {
color: var(--review-card-star-color); color: var(--review-card-star-color);
} }
@ -29,10 +32,6 @@
overflow: hidden; overflow: hidden;
} }
.card-footer {
width: 288px;
}
.card { .card {
cursor: pointer; cursor: pointer;
} }

View File

@ -1,5 +1,5 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common'; import {CommonModule, NgOptimizedImage} from '@angular/common';
import {UserReview} from "./user-review"; import {UserReview} from "./user-review";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {ReviewCardModalComponent} from "../review-card-modal/review-card-modal.component"; import {ReviewCardModalComponent} from "../review-card-modal/review-card-modal.component";
@ -7,11 +7,13 @@ import {AccountService} from "../../_services/account.service";
import {ReviewSeriesModalComponent} from "../review-series-modal/review-series-modal.component"; import {ReviewSeriesModalComponent} from "../review-series-modal/review-series-modal.component";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {DefaultValuePipe} from "../../pipe/default-value.pipe"; import {DefaultValuePipe} from "../../pipe/default-value.pipe";
import {ImageComponent} from "../../shared/image/image.component";
import {ProviderImagePipe} from "../../pipe/provider-image.pipe";
@Component({ @Component({
selector: 'app-review-card', selector: 'app-review-card',
standalone: true, standalone: true,
imports: [CommonModule, ReadMoreComponent, DefaultValuePipe], imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe],
templateUrl: './review-card.component.html', templateUrl: './review-card.component.html',
styleUrls: ['./review-card.component.scss'], styleUrls: ['./review-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -1,3 +1,5 @@
import {ScrobbleProvider} from "../../_services/scrobbling.service";
export interface UserReview { export interface UserReview {
seriesId: number; seriesId: number;
libraryId: number; libraryId: number;
@ -8,4 +10,5 @@ export interface UserReview {
isExternal: boolean; isExternal: boolean;
bodyJustText?: string; bodyJustText?: string;
externalUrl?: string; externalUrl?: string;
provider: ScrobbleProvider;
} }

View File

@ -36,12 +36,9 @@
<app-manage-tasks-settings></app-manage-tasks-settings> <app-manage-tasks-settings></app-manage-tasks-settings>
</ng-container> </ng-container>
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus"> <ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
<p>Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">premium benefits</a> today! <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">FAQ</a></p> <p>Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">premium benefits</a> today! <a href="https://wiki.kavitareader.com/en/kavita-plus#faq" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
<app-license></app-license> <app-license></app-license>
</ng-container> </ng-container>
<ng-container *ngIf="tab.fragment === TabID.Plugins">
Nothing here yet. This will be built out in a future update.
</ng-container>
</ng-template> </ng-template>
</li> </li>
</ul> </ul>

View File

@ -100,7 +100,7 @@
<div class="row g-0 mb-2 mt-3"> <div class="row g-0 mb-2 mt-3">
<div class="col-md-4 col-sm-12 pe-2"> <div class="col-md-4 col-sm-12 pe-2">
<label for="cache-size" class="form-label">Cache Size</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheSizeTooltip" role="button" tabindex="0"></i> <label for="cache-size" class="form-label">Cache Size</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheSizeTooltip" role="button" tabindex="0"></i>
<ng-template #cacheSizeTooltip>The amount of memory allowed for caching heavy APIs. Default is 50MB.</ng-template> <ng-template #cacheSizeTooltip>The amount of memory allowed for caching heavy APIs. Default is 75MB.</ng-template>
<span class="visually-hidden" id="cache-size-help">The amount of memory allowed for caching heavy APIs. Default is 50MB.</span> <span class="visually-hidden" id="cache-size-help">The amount of memory allowed for caching heavy APIs. Default is 50MB.</span>
<input id="cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize" <input id="cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize"
type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57" type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57"

View File

@ -13,6 +13,8 @@ export class ProviderImagePipe implements PipeTransform {
return 'assets/images/ExternalServices/AniList.png'; return 'assets/images/ExternalServices/AniList.png';
case ScrobbleProvider.Mal: case ScrobbleProvider.Mal:
return 'assets/images/ExternalServices/MAL.png'; return 'assets/images/ExternalServices/MAL.png';
case ScrobbleProvider.Kavita:
return 'assets/images/logo-32.png';
} }
return ''; return '';

View File

@ -0,0 +1,23 @@
import { Pipe, PipeTransform } from '@angular/core';
import {ScrobbleProvider} from "../_services/scrobbling.service";
@Pipe({
name: 'providerName',
standalone: true
})
export class ProviderNamePipe implements PipeTransform {
transform(value: ScrobbleProvider): string {
switch (value) {
case ScrobbleProvider.AniList:
return 'AniList';
case ScrobbleProvider.Mal:
return 'MAL';
case ScrobbleProvider.Kavita:
return 'Kavita';
}
return '';
}
}

View File

@ -1,13 +1,16 @@
<div class="row"> <div class="row">
<div class="col-auto custom-col" style="cursor: pointer" [ngbPopover]="popContent" <div class="col-auto custom-col clickable" [ngbPopover]="popContent"
popoverTitle="Series Rating"> popoverTitle="Your Rating + Overall" popoverClass="md-popover">
<span class="badge rounded-pill me-1"> <span class="badge rounded-pill me-1">
<img class="me-1" ngSrc="assets/images/logo-32.png" width="24" height="24" alt=""> <img class="me-1" ngSrc="assets/images/logo-32.png" width="24" height="24" alt="">
{{userRating * 20}}% {{userRating * 20}}
<ng-container *ngIf="overallRating > 0; else noOverallRating"> + {{overallRating}}%</ng-container>
<ng-template #noOverallRating>%</ng-template>
</span> </span>
</div> </div>
<div class="col-auto custom-col" *ngFor="let rating of ratings"> <div class="col-auto custom-col clickable" *ngFor="let rating of ratings" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
[popoverTitle]="rating.provider | providerName" popoverClass="sm-popover">
<span class="badge rounded-pill me-1"> <span class="badge rounded-pill me-1">
<img class="me-1" [ngSrc]="rating.provider | providerImage" width="24" height="24" alt=""> <img class="me-1" [ngSrc]="rating.provider | providerImage" width="24" height="24" alt="">
{{rating.averageScore}}% {{rating.averageScore}}%
@ -23,5 +26,9 @@
<ng-template let-fill="fill" let-index="index"> <ng-template let-fill="fill" let-index="index">
<span class="star" [class.filled]="(index < userRating) && userRating > 0">&#9733;</span> <span class="star" [class.filled]="(index < userRating) && userRating > 0">&#9733;</span>
</ng-template> </ng-template>
</ngb-rating> </ngb-rating> {{userRating * 20}}%
</ng-template>
<ng-template #externalPopContent let-rating="rating">
<i class="fa-solid fa-heart" aria-hidden="true"></i> {{rating.favoriteCount}}
</ng-template> </ng-template>

View File

@ -2,3 +2,21 @@
padding-left: 0px; padding-left: 0px;
padding-right: 0px; padding-right: 0px;
} }
.sm-popover {
width: 150px;
> .popover-body {
padding-top: 0px;
}
}
.md-popover {
width: 214px;
> .popover-body {
padding-top: 0px;
}
}

View File

@ -1,4 +1,12 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
OnInit,
ViewEncapsulation
} from '@angular/core';
import {CommonModule, NgOptimizedImage} from '@angular/common'; import {CommonModule, NgOptimizedImage} from '@angular/common';
import {SeriesService} from "../../../_services/series.service"; import {SeriesService} from "../../../_services/series.service";
import {Rating} from "../../../_models/rating"; import {Rating} from "../../../_models/rating";
@ -7,14 +15,16 @@ import {NgbPopover, NgbRating} from "@ng-bootstrap/ng-bootstrap";
import {LoadingComponent} from "../../../shared/loading/loading.component"; import {LoadingComponent} from "../../../shared/loading/loading.component";
import {AccountService} from "../../../_services/account.service"; import {AccountService} from "../../../_services/account.service";
import {LibraryType} from "../../../_models/library"; import {LibraryType} from "../../../_models/library";
import {ProviderNamePipe} from "../../../pipe/provider-name.pipe";
@Component({ @Component({
selector: 'app-external-rating', selector: 'app-external-rating',
standalone: true, standalone: true,
imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent], imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe],
templateUrl: './external-rating.component.html', templateUrl: './external-rating.component.html',
styleUrls: ['./external-rating.component.scss'], styleUrls: ['./external-rating.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
}) })
export class ExternalRatingComponent implements OnInit { export class ExternalRatingComponent implements OnInit {
@Input({required: true}) seriesId!: number; @Input({required: true}) seriesId!: number;
@ -26,10 +36,13 @@ export class ExternalRatingComponent implements OnInit {
ratings: Array<Rating> = []; ratings: Array<Rating> = [];
isLoading: boolean = false; isLoading: boolean = false;
overallRating: number = -1;
ngOnInit() { ngOnInit() {
this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore);
this.accountService.hasValidLicense$.subscribe((res) => { this.accountService.hasValidLicense$.subscribe((res) => {
if (!res) return; if (!res) return;
this.isLoading = true; this.isLoading = true;

View File

@ -6,7 +6,7 @@ export const environment = {
production: false, production: false,
apiUrl: 'http://localhost:5000/api/', apiUrl: 'http://localhost:5000/api/',
hubUrl: 'http://localhost:5000/hubs/', hubUrl: 'http://localhost:5000/hubs/',
buyLink: 'https://buy.stripe.com/test_8wM4ie2dg5j77o4cMO?prefilled_promo_code=FREETRIAL', buyLink: 'https://buy.stripe.com/test_9AQ5mi058h1PcIo3cf?prefilled_promo_code=FREETRIAL',
manageLink: 'https://billing.stripe.com/p/login/test_14kfZocuh6Tz5ag7ss' manageLink: 'https://billing.stripe.com/p/login/test_14kfZocuh6Tz5ag7ss'
}; };

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.7.4.1" "version": "0.7.4.2"
}, },
"servers": [ "servers": [
{ {
@ -2827,7 +2827,7 @@
"tags": [ "tags": [
"License" "License"
], ],
"summary": "Updates server license. Returns true if updated and valid", "summary": "Updates server license",
"description": "Caches the result", "description": "Caches the result",
"requestBody": { "requestBody": {
"content": { "content": {
@ -3947,6 +3947,45 @@
} }
} }
}, },
"/api/Rating/overall": {
"get": {
"tags": [
"Rating"
],
"parameters": [
{
"name": "seriesId",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/RatingDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/RatingDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/RatingDto"
}
}
}
}
}
}
},
"/api/Reader/pdf": { "/api/Reader/pdf": {
"get": { "get": {
"tags": [ "tags": [
@ -6525,9 +6564,12 @@
"in": "query", "in": "query",
"schema": { "schema": {
"enum": [ "enum": [
1 0,
1,
2
], ],
"type": "integer", "type": "integer",
"description": "Misleading name but is the source of data (like a review coming from AniList)",
"format": "int32" "format": "int32"
} }
} }
@ -8540,7 +8582,7 @@
"tags": [ "tags": [
"Server" "Server"
], ],
"summary": "Bust Review and Recommendation Cache", "summary": "Bust Kavita+ Cache",
"responses": { "responses": {
"200": { "200": {
"description": "Success" "description": "Success"
@ -13731,9 +13773,12 @@
}, },
"provider": { "provider": {
"enum": [ "enum": [
1 0,
1,
2
], ],
"type": "integer", "type": "integer",
"description": "Misleading name but is the source of data (like a review coming from AniList)",
"format": "int32" "format": "int32"
} }
}, },
@ -17180,6 +17225,16 @@
"type": "string", "type": "string",
"description": "The main body with just text, for review preview", "description": "The main body with just text, for review preview",
"nullable": true "nullable": true
},
"provider": {
"enum": [
0,
1,
2
],
"type": "integer",
"description": "If this review is External, which Provider did it come from",
"format": "int32"
} }
}, },
"additionalProperties": false, "additionalProperties": false,