mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Kavita+ Enhancements (#2616)
This commit is contained in:
parent
625c56b265
commit
dd44f55747
@ -233,7 +233,7 @@ public class DownloadController : BaseApiController
|
||||
MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), $"Downloading {filename}", 1F));
|
||||
|
||||
|
||||
return PhysicalFile(filePath, DefaultContentType, System.Web.HttpUtility.UrlEncode(filename), true);
|
||||
return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(filename), true);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -2,7 +2,6 @@
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.DTOs.Account;
|
||||
using API.DTOs.License;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
@ -20,7 +19,8 @@ public class LicenseController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ILogger<LicenseController> logger,
|
||||
ILicenseService licenseService,
|
||||
ILocalizationService localizationService)
|
||||
ILocalizationService localizationService,
|
||||
ITaskScheduler taskScheduler)
|
||||
: BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
@ -31,7 +31,12 @@ public class LicenseController(
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
|
||||
public async Task<ActionResult<bool>> HasValidLicense(bool forceCheck = false)
|
||||
{
|
||||
return Ok(await licenseService.HasActiveLicense(forceCheck));
|
||||
var ret = await licenseService.HasActiveLicense(forceCheck);
|
||||
if (ret)
|
||||
{
|
||||
await taskScheduler.ScheduleKavitaPlusTasks();
|
||||
}
|
||||
return Ok(ret);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -57,6 +62,7 @@ public class LicenseController(
|
||||
setting.Value = null;
|
||||
unitOfWork.SettingsRepository.Update(setting);
|
||||
await unitOfWork.CommitAsync();
|
||||
await taskScheduler.ScheduleKavitaPlusTasks();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@ -82,6 +88,7 @@ public class LicenseController(
|
||||
try
|
||||
{
|
||||
await licenseService.AddLicense(dto.License.Trim(), dto.Email.Trim(), dto.DiscordId);
|
||||
await taskScheduler.ScheduleKavitaPlusTasks();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
@ -8,9 +8,11 @@ using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using Kavita.Common.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -18,17 +20,10 @@ namespace API.Controllers;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class MetadataController : BaseApiController
|
||||
public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILicenseService licenseService,
|
||||
IRatingService ratingService, IReviewService reviewService, IRecommendationService recommendationService, IExternalMetadataService metadataService)
|
||||
: BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
|
||||
public MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_localizationService = localizationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches genres from the instance
|
||||
/// </summary>
|
||||
@ -41,10 +36,10 @@ public class MetadataController : BaseApiController
|
||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||
if (ids != null && ids.Count > 0)
|
||||
{
|
||||
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
|
||||
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
|
||||
}
|
||||
|
||||
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId()));
|
||||
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -57,8 +52,8 @@ public class MetadataController : BaseApiController
|
||||
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
|
||||
{
|
||||
return role.HasValue ?
|
||||
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
|
||||
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
|
||||
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
|
||||
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -73,9 +68,9 @@ public class MetadataController : BaseApiController
|
||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||
if (ids != null && ids.Count > 0)
|
||||
{
|
||||
return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
|
||||
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
|
||||
}
|
||||
return Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
|
||||
return Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -90,9 +85,9 @@ public class MetadataController : BaseApiController
|
||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||
if (ids != null && ids.Count > 0)
|
||||
{
|
||||
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
|
||||
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
|
||||
}
|
||||
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId()));
|
||||
return Ok(await unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -108,7 +103,7 @@ public class MetadataController : BaseApiController
|
||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||
if (ids != null && ids.Count > 0)
|
||||
{
|
||||
return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
|
||||
return Ok(await unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
return Ok(Enum.GetValues<AgeRating>().Select(t => new AgeRatingDto()
|
||||
@ -131,7 +126,7 @@ public class MetadataController : BaseApiController
|
||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||
if (ids is {Count: > 0})
|
||||
{
|
||||
return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
|
||||
return Ok(unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
return Ok(Enum.GetValues<PublicationStatus>().Select(t => new PublicationStatusDto()
|
||||
@ -152,10 +147,13 @@ public class MetadataController : BaseApiController
|
||||
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
|
||||
{
|
||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
|
||||
return Ok(await unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns all languages Kavita can accept
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("all-languages")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
|
||||
public IEnumerable<LanguageDto> GetAllValidLanguages()
|
||||
@ -177,9 +175,38 @@ public class MetadataController : BaseApiController
|
||||
[HttpGet("chapter-summary")]
|
||||
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
|
||||
{
|
||||
if (chapterId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||
if (chapterId <= 0) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||
return Ok(chapter.Summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the details needed from Kavita+ for Series Detail page
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("series-detail-plus")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
|
||||
public async Task<ActionResult<SeriesDetailPlusDto>> GetKavitaPlusSeriesDetailData(int seriesId)
|
||||
{
|
||||
var seriesDetail = new SeriesDetailPlusDto();
|
||||
if (!await licenseService.HasActiveLicense())
|
||||
{
|
||||
seriesDetail.Recommendations = null;
|
||||
seriesDetail.Ratings = Enumerable.Empty<RatingDto>();
|
||||
return Ok(seriesDetail);
|
||||
}
|
||||
|
||||
seriesDetail = await metadataService.GetSeriesDetail(User.GetUserId(), seriesId);
|
||||
|
||||
// Temp solution, needs to be updated with new API
|
||||
// seriesDetail.Ratings = await ratingService.GetRatings(seriesId);
|
||||
// seriesDetail.Reviews = await reviewService.GetReviewsForSeries(User.GetUserId(), seriesId);
|
||||
// seriesDetail.Recommendations =
|
||||
// await recommendationService.GetRecommendationsForSeries(User.GetUserId(), seriesId);
|
||||
|
||||
return Ok(seriesDetail);
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -44,26 +44,14 @@ public class RatingController : BaseApiController
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
|
||||
public async Task<ActionResult<IEnumerable<RatingDto>>> GetRating(int seriesId)
|
||||
{
|
||||
|
||||
if (!await _licenseService.HasActiveLicense())
|
||||
{
|
||||
return Ok(Enumerable.Empty<RatingDto>());
|
||||
}
|
||||
|
||||
var cacheKey = CacheKey + seriesId;
|
||||
var results = await _cacheProvider.GetAsync<IEnumerable<RatingDto>>(cacheKey);
|
||||
if (results.HasValue)
|
||||
{
|
||||
return Ok(results.Value);
|
||||
}
|
||||
|
||||
var ratings = await _ratingService.GetRatings(seriesId);
|
||||
await _cacheProvider.SetAsync(cacheKey, ratings, TimeSpan.FromHours(24));
|
||||
_logger.LogDebug("Caching external rating for {Key}", cacheKey);
|
||||
return Ok(ratings);
|
||||
return Ok(await _ratingService.GetRatings(seriesId));
|
||||
}
|
||||
|
||||
[HttpGet("overall")]
|
||||
|
@ -51,78 +51,10 @@ public class ReviewController : BaseApiController
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
[HttpGet]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
|
||||
public async Task<ActionResult<IEnumerable<UserReviewDto>>> GetReviews(int seriesId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var username = User.GetUsername();
|
||||
var userRatings = (await _unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, userId))
|
||||
.Where(r => !string.IsNullOrEmpty(r.Body))
|
||||
.OrderByDescending(review => review.Username.Equals(username) ? 1 : 0)
|
||||
.ToList();
|
||||
if (!await _licenseService.HasActiveLicense())
|
||||
{
|
||||
return Ok(userRatings);
|
||||
}
|
||||
|
||||
var cacheKey = CacheKey + seriesId;
|
||||
IList<UserReviewDto> externalReviews;
|
||||
|
||||
var result = await _cacheProvider.GetAsync<IEnumerable<UserReviewDto>>(cacheKey);
|
||||
if (result.HasValue)
|
||||
{
|
||||
externalReviews = result.Value.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var reviews = (await _reviewService.GetReviewsForSeries(userId, seriesId)).ToList();
|
||||
externalReviews = SelectSpectrumOfReviews(reviews);
|
||||
|
||||
await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10));
|
||||
_logger.LogDebug("Caching external reviews for {Key}", cacheKey);
|
||||
}
|
||||
|
||||
|
||||
// Fetch external reviews and splice them in
|
||||
userRatings.AddRange(externalReviews);
|
||||
|
||||
|
||||
return Ok(userRatings);
|
||||
}
|
||||
|
||||
private static IList<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
|
||||
{
|
||||
IList<UserReviewDto> externalReviews;
|
||||
var totalReviews = reviews.Count;
|
||||
|
||||
if (totalReviews > 10)
|
||||
{
|
||||
var stepSize = Math.Max((totalReviews - 4) / 8, 1);
|
||||
|
||||
var selectedReviews = new List<UserReviewDto>()
|
||||
{
|
||||
reviews[0],
|
||||
reviews[1],
|
||||
};
|
||||
for (var i = 2; i < totalReviews - 2; i += stepSize)
|
||||
{
|
||||
selectedReviews.Add(reviews[i]);
|
||||
|
||||
if (selectedReviews.Count >= 8)
|
||||
break;
|
||||
}
|
||||
|
||||
selectedReviews.Add(reviews[totalReviews - 2]);
|
||||
selectedReviews.Add(reviews[totalReviews - 1]);
|
||||
|
||||
externalReviews = selectedReviews;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalReviews = reviews;
|
||||
}
|
||||
|
||||
return externalReviews;
|
||||
return Ok(await _reviewService.GetReviewsForSeries(User.GetUserId(), seriesId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
15
API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs
Normal file
15
API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Recommendation;
|
||||
|
||||
namespace API.DTOs.SeriesDetail;
|
||||
|
||||
/// <summary>
|
||||
/// All the data from Kavita+ for Series Detail
|
||||
/// </summary>
|
||||
/// <remarks>This is what the UI sees, not what the API sends back</remarks>
|
||||
public class SeriesDetailPlusDto
|
||||
{
|
||||
public RecommendationDto Recommendations { get; set; }
|
||||
public IEnumerable<UserReviewDto> Reviews { get; set; }
|
||||
public IEnumerable<RatingDto> Ratings { get; set; }
|
||||
}
|
@ -1,11 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common;
|
||||
@ -29,9 +34,17 @@ internal class ExternalMetadataIdsDto
|
||||
public MediaFormat? PlusMediaFormat { get; set; } = MediaFormat.Unknown;
|
||||
}
|
||||
|
||||
internal class SeriesDetailPlusAPIDto
|
||||
{
|
||||
public IEnumerable<MediaRecommendationDto> Recommendations { get; set; }
|
||||
public IEnumerable<UserReviewDto> Reviews { get; set; }
|
||||
public IEnumerable<RatingDto> Ratings { get; set; }
|
||||
}
|
||||
|
||||
public interface IExternalMetadataService
|
||||
{
|
||||
Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId);
|
||||
Task<SeriesDetailPlusDto?> GetSeriesDetail(int userId, int seriesId);
|
||||
}
|
||||
|
||||
public class ExternalMetadataService : IExternalMetadataService
|
||||
@ -48,6 +61,14 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves Metadata about a Recommended External Series
|
||||
/// </summary>
|
||||
/// <param name="aniListId"></param>
|
||||
/// <param name="malId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException"></exception>
|
||||
public async Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId)
|
||||
{
|
||||
if (!aniListId.HasValue && !malId.HasValue)
|
||||
@ -60,6 +81,92 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
|
||||
}
|
||||
|
||||
public async Task<SeriesDetailPlusDto?> GetSeriesDetail(int userId, int seriesId)
|
||||
{
|
||||
var series =
|
||||
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
||||
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters);
|
||||
if (series == null || series.Library.Type == LibraryType.Comic) return new SeriesDetailPlusDto();
|
||||
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/series-detail")
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-license-key", license)
|
||||
.WithHeader("x-installId", HashUtil.ServerToken())
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
||||
.PostJsonAsync(new PlusSeriesDtoBuilder(series).Build())
|
||||
.ReceiveJson<SeriesDetailPlusAPIDto>();
|
||||
|
||||
|
||||
var recs = await ProcessRecommendations(series, user!, result.Recommendations);
|
||||
return new SeriesDetailPlusDto()
|
||||
{
|
||||
Recommendations = recs,
|
||||
Ratings = result.Ratings,
|
||||
Reviews = result.Reviews
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An error happened during the request to Kavita+ API");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RecommendationDto> ProcessRecommendations(Series series, AppUser user, IEnumerable<MediaRecommendationDto> recs)
|
||||
{
|
||||
var recDto = new RecommendationDto()
|
||||
{
|
||||
ExternalSeries = new List<ExternalSeriesDto>(),
|
||||
OwnedSeries = new List<SeriesDto>()
|
||||
};
|
||||
|
||||
var canSeeExternalSeries = user is {AgeRestriction: AgeRating.NotApplicable} &&
|
||||
await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||
foreach (var rec in recs)
|
||||
{
|
||||
// Find the series based on name and type and that the user has access too
|
||||
var seriesForRec = await _unitOfWork.SeriesRepository.GetSeriesDtoByNamesAndMetadataIdsForUser(user.Id, rec.RecommendationNames,
|
||||
series.Library.Type, ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, rec.AniListId),
|
||||
ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, rec.MalId));
|
||||
|
||||
if (seriesForRec != null)
|
||||
{
|
||||
recDto.OwnedSeries.Add(seriesForRec);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!canSeeExternalSeries) continue;
|
||||
// We can show this based on user permissions
|
||||
if (string.IsNullOrEmpty(rec.Name) || string.IsNullOrEmpty(rec.SiteUrl) || string.IsNullOrEmpty(rec.CoverUrl)) continue;
|
||||
recDto.ExternalSeries.Add(new ExternalSeriesDto()
|
||||
{
|
||||
Name = string.IsNullOrEmpty(rec.Name) ? rec.RecommendationNames.First() : rec.Name,
|
||||
Url = rec.SiteUrl,
|
||||
CoverUrl = rec.CoverUrl,
|
||||
Summary = rec.Summary,
|
||||
AniListId = rec.AniListId,
|
||||
MalId = rec.MalId
|
||||
});
|
||||
}
|
||||
|
||||
await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, recDto.OwnedSeries);
|
||||
|
||||
recDto.OwnedSeries = recDto.OwnedSeries.DistinctBy(s => s.Id).OrderBy(r => r.Name).ToList();
|
||||
recDto.ExternalSeries = recDto.ExternalSeries.DistinctBy(s => s.Name.ToNormalized()).OrderBy(r => r.Name).ToList();
|
||||
|
||||
return recDto;
|
||||
}
|
||||
|
||||
|
||||
private async Task<ExternalSeriesDetailDto?> GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId)
|
||||
{
|
||||
var payload = new ExternalMetadataIdsDto()
|
||||
|
@ -7,7 +7,6 @@ using API.DTOs.License;
|
||||
using API.Entities.Enums;
|
||||
using EasyCaching.Core;
|
||||
using Flurl.Http;
|
||||
using Hangfire;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -24,31 +23,25 @@ internal class RegisterLicenseResponseDto
|
||||
|
||||
public interface ILicenseService
|
||||
{
|
||||
Task ValidateLicenseStatus();
|
||||
//Task ValidateLicenseStatus();
|
||||
Task RemoveLicense();
|
||||
Task AddLicense(string license, string email, string? discordId);
|
||||
Task<bool> HasActiveLicense(bool forceCheck = false);
|
||||
Task<bool> HasActiveSubscription(string? license);
|
||||
Task<bool> ResetLicense(string license, string email);
|
||||
}
|
||||
|
||||
public class LicenseService : ILicenseService
|
||||
public class LicenseService(
|
||||
IEasyCachingProviderFactory cachingProviderFactory,
|
||||
IUnitOfWork unitOfWork,
|
||||
ILogger<LicenseService> logger)
|
||||
: ILicenseService
|
||||
{
|
||||
private readonly IEasyCachingProviderFactory _cachingProviderFactory;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<LicenseService> _logger;
|
||||
private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8);
|
||||
public const string Cron = "0 */4 * * *";
|
||||
private const string CacheKey = "license";
|
||||
|
||||
|
||||
public LicenseService(IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork, ILogger<LicenseService> logger)
|
||||
{
|
||||
_cachingProviderFactory = cachingProviderFactory;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Performs license lookup to API layer
|
||||
/// </summary>
|
||||
@ -77,7 +70,7 @@ public class LicenseService : ILicenseService
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An error happened during the request to Kavita+ API");
|
||||
logger.LogError(e, "An error happened during the request to Kavita+ API");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@ -115,12 +108,12 @@ public class LicenseService : ILicenseService
|
||||
return response.EncryptedLicense;
|
||||
}
|
||||
|
||||
_logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response.ErrorMessage);
|
||||
logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response.ErrorMessage);
|
||||
throw new KavitaException(response.ErrorMessage);
|
||||
}
|
||||
catch (FlurlHttpException e)
|
||||
{
|
||||
_logger.LogError(e, "An error happened during the request to Kavita+ API");
|
||||
logger.LogError(e, "An error happened during the request to Kavita+ API");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@ -129,57 +122,41 @@ public class LicenseService : ILicenseService
|
||||
/// Checks licenses and updates cache
|
||||
/// </summary>
|
||||
/// <remarks>Expected to be called at startup and on reoccurring basis</remarks>
|
||||
public async Task ValidateLicenseStatus()
|
||||
{
|
||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
try
|
||||
{
|
||||
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
if (string.IsNullOrEmpty(license.Value)) {
|
||||
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Validating Kavita+ License");
|
||||
|
||||
await provider.FlushAsync();
|
||||
var isValid = await IsLicenseValid(license.Value);
|
||||
await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
|
||||
|
||||
_logger.LogInformation("Validating Kavita+ License - Complete");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins");
|
||||
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
||||
BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveLicense()
|
||||
{
|
||||
var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
serverSetting.Value = string.Empty;
|
||||
_unitOfWork.SettingsRepository.Update(serverSetting);
|
||||
await _unitOfWork.CommitAsync();
|
||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
await provider.RemoveAsync(CacheKey);
|
||||
}
|
||||
|
||||
public async Task AddLicense(string license, string email, string? discordId)
|
||||
{
|
||||
var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
var lic = await RegisterLicense(license, email, discordId);
|
||||
if (string.IsNullOrWhiteSpace(lic))
|
||||
throw new KavitaException("unable-to-register-k+");
|
||||
serverSetting.Value = lic;
|
||||
_unitOfWork.SettingsRepository.Update(serverSetting);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
// public async Task ValidateLicenseStatus()
|
||||
// {
|
||||
// var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
// try
|
||||
// {
|
||||
// var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
// if (string.IsNullOrEmpty(license.Value)) {
|
||||
// await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// _logger.LogInformation("Validating Kavita+ License");
|
||||
//
|
||||
// await provider.FlushAsync();
|
||||
// var isValid = await IsLicenseValid(license.Value);
|
||||
// await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
|
||||
//
|
||||
// _logger.LogInformation("Validating Kavita+ License - Complete");
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// _logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins");
|
||||
// await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
||||
// BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30));
|
||||
// }
|
||||
// }
|
||||
|
||||
/// <summary>
|
||||
/// Checks licenses and updates cache
|
||||
/// </summary>
|
||||
/// <param name="forceCheck">Skip what's in cache</param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> HasActiveLicense(bool forceCheck = false)
|
||||
{
|
||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
if (!forceCheck)
|
||||
{
|
||||
var cacheValue = await provider.GetAsync<bool>(CacheKey);
|
||||
@ -188,7 +165,7 @@ public class LicenseService : ILicenseService
|
||||
|
||||
try
|
||||
{
|
||||
var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
var result = await IsLicenseValid(serverSetting.Value);
|
||||
await provider.FlushAsync();
|
||||
await provider.SetAsync(CacheKey, result, _licenseCacheTimeout);
|
||||
@ -196,17 +173,77 @@ public class LicenseService : ILicenseService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue connecting to Kavita+");
|
||||
logger.LogError(ex, "There was an issue connecting to Kavita+");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> HasActiveSubscription(string? license)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(license)) return false;
|
||||
try
|
||||
{
|
||||
var response = await (Configuration.KavitaPlusApiUrl + "/api/license/check-sub")
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-license-key", license)
|
||||
.WithHeader("x-installId", HashUtil.ServerToken())
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
||||
.PostJsonAsync(new LicenseValidDto()
|
||||
{
|
||||
License = license,
|
||||
InstallId = HashUtil.ServerToken()
|
||||
})
|
||||
.ReceiveString();
|
||||
var result = bool.Parse(response);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
await provider.FlushAsync();
|
||||
await provider.SetAsync(CacheKey, result, _licenseCacheTimeout);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "An error happened during the request to Kavita+ API");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveLicense()
|
||||
{
|
||||
var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
serverSetting.Value = string.Empty;
|
||||
unitOfWork.SettingsRepository.Update(serverSetting);
|
||||
await unitOfWork.CommitAsync();
|
||||
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
await provider.RemoveAsync(CacheKey);
|
||||
}
|
||||
|
||||
public async Task AddLicense(string license, string email, string? discordId)
|
||||
{
|
||||
var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
var lic = await RegisterLicense(license, email, discordId);
|
||||
if (string.IsNullOrWhiteSpace(lic))
|
||||
throw new KavitaException("unable-to-register-k+");
|
||||
serverSetting.Value = lic;
|
||||
unitOfWork.SettingsRepository.Update(serverSetting);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task<bool> ResetLicense(string license, string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var encryptedLicense = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
var response = await (Configuration.KavitaPlusApiUrl + "/api/license/reset")
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
@ -225,17 +262,17 @@ public class LicenseService : ILicenseService
|
||||
|
||||
if (string.IsNullOrEmpty(response))
|
||||
{
|
||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
await provider.RemoveAsync(CacheKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response);
|
||||
logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response);
|
||||
throw new KavitaException(response);
|
||||
}
|
||||
catch (FlurlHttpException e)
|
||||
{
|
||||
_logger.LogError(e, "An error happened during the request to Kavita+ API");
|
||||
logger.LogError(e, "An error happened during the request to Kavita+ API");
|
||||
}
|
||||
|
||||
return false;
|
||||
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
@ -10,6 +11,7 @@ using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using EasyCaching.Core;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
@ -28,25 +30,51 @@ public class RatingService : IRatingService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<RatingService> _logger;
|
||||
private readonly IEasyCachingProvider _cacheProvider;
|
||||
|
||||
public RatingService(IUnitOfWork unitOfWork, ILogger<RatingService> logger)
|
||||
public const string CacheKey = "rating_";
|
||||
|
||||
public RatingService(IUnitOfWork unitOfWork, ILogger<RatingService> logger, IEasyCachingProviderFactory cachingProviderFactory)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
|
||||
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
|
||||
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches Ratings for a given Series. Will check cache first
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<RatingDto>> GetRatings(int seriesId)
|
||||
{
|
||||
var cacheKey = CacheKey + seriesId;
|
||||
var results = await _cacheProvider.GetAsync<IEnumerable<RatingDto>>(cacheKey);
|
||||
if (results.HasValue)
|
||||
{
|
||||
return results.Value;
|
||||
}
|
||||
|
||||
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
||||
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Chapters | SeriesIncludes.Volumes);
|
||||
|
||||
// Don't send any ratings back for Comic libraries as Kavita+ doesn't have any providers for that
|
||||
if (series == null || series.Library.Type == LibraryType.Comic) return ImmutableList<RatingDto>.Empty;
|
||||
return await GetRatings(license.Value, series);
|
||||
if (series == null || series.Library.Type == LibraryType.Comic)
|
||||
{
|
||||
await _cacheProvider.SetAsync(cacheKey, ImmutableList<RatingDto>.Empty, TimeSpan.FromHours(24));
|
||||
return ImmutableList<RatingDto>.Empty;
|
||||
}
|
||||
|
||||
var ratings = (await GetRatings(license.Value, series)).ToList();
|
||||
await _cacheProvider.SetAsync(cacheKey, ratings, TimeSpan.FromHours(24));
|
||||
_logger.LogDebug("Caching external rating for {Key}", cacheKey);
|
||||
|
||||
return ratings;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<RatingDto>> GetRatings(string license, Series series)
|
||||
|
@ -40,7 +40,7 @@ public record PlusSeriesDto
|
||||
public int? Year { get; set; }
|
||||
}
|
||||
|
||||
internal record MediaRecommendationDto
|
||||
public record MediaRecommendationDto
|
||||
{
|
||||
public int Rating { get; set; }
|
||||
public IEnumerable<string> RecommendationNames { get; set; } = null!;
|
||||
@ -126,7 +126,7 @@ public class RecommendationService : IRecommendationService
|
||||
}
|
||||
|
||||
|
||||
private async Task<IEnumerable<MediaRecommendationDto>> GetRecommendations(string license, Series series)
|
||||
protected async Task<IEnumerable<MediaRecommendationDto>> GetRecommendations(string license, Series series)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -7,7 +7,6 @@ using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -78,7 +77,7 @@ public class ScrobblingService : IScrobblingService
|
||||
{MangaDexWeblinkWebsite, 0},
|
||||
};
|
||||
|
||||
private const int ScrobbleSleepTime = 700; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90)
|
||||
private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90)
|
||||
|
||||
private static readonly IList<ScrobbleProvider> BookProviders = new List<ScrobbleProvider>()
|
||||
{
|
||||
@ -425,8 +424,17 @@ public class ScrobblingService : IScrobblingService
|
||||
if (response.ErrorMessage != null && response.ErrorMessage.Contains("Too Many Requests"))
|
||||
{
|
||||
_logger.LogInformation("Hit Too many requests, sleeping to regain requests");
|
||||
await Task.Delay(TimeSpan.FromMinutes(1));
|
||||
} else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series"))
|
||||
await Task.Delay(TimeSpan.FromMinutes(5));
|
||||
} else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unauthorized"))
|
||||
{
|
||||
_logger.LogInformation("Kavita+ responded with Unauthorized. Please check your subscription");
|
||||
await _licenseService.HasActiveLicense(true);
|
||||
throw new KavitaException("Kavita+ responded with Unauthorized. Please check your subscription");
|
||||
} else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Access token is invalid"))
|
||||
{
|
||||
throw new KavitaException("Access token is invalid");
|
||||
}
|
||||
else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series"))
|
||||
{
|
||||
// Log the Series name and Id in ScrobbleErrors
|
||||
_logger.LogInformation("Kavita+ was unable to match the series");
|
||||
@ -615,10 +623,7 @@ public class ScrobblingService : IScrobblingService
|
||||
.Where(e => librariesWithScrobbling.Contains(e.LibraryId))
|
||||
.Where(e => !errors.Contains(e.SeriesId))
|
||||
.ToList();
|
||||
// var reviewEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.Review))
|
||||
// .Where(e => librariesWithScrobbling.Contains(e.LibraryId))
|
||||
// .Where(e => !errors.Contains(e.SeriesId))
|
||||
// .ToList();
|
||||
|
||||
var decisions = addToWantToRead
|
||||
.GroupBy(item => new { item.SeriesId, item.AppUserId })
|
||||
.Select(group => new
|
||||
@ -645,7 +650,7 @@ public class ScrobblingService : IScrobblingService
|
||||
await SetAndCheckRateLimit(userRateLimits, user, license.Value);
|
||||
}
|
||||
|
||||
var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count;// + reviewEvents.Count;
|
||||
var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count;
|
||||
|
||||
_logger.LogInformation("Found {TotalEvents} Scrobble Events", totalProgress);
|
||||
try
|
||||
@ -692,22 +697,6 @@ public class ScrobblingService : IScrobblingService
|
||||
Year = evt.Series.Metadata.ReleaseYear
|
||||
}));
|
||||
|
||||
// progressCounter = await ProcessEvents(reviewEvents, userRateLimits, usersToScrobble.Count, progressCounter,
|
||||
// totalProgress, evt => Task.FromResult(new ScrobbleDto()
|
||||
// {
|
||||
// Format = evt.Format,
|
||||
// AniListId = evt.AniListId,
|
||||
// MALId = (int?) evt.MalId,
|
||||
// ScrobbleEventType = evt.ScrobbleEventType,
|
||||
// AniListToken = evt.AppUser.AniListAccessToken,
|
||||
// SeriesName = evt.Series.Name,
|
||||
// LocalizedSeriesName = evt.Series.LocalizedName,
|
||||
// Rating = evt.Rating,
|
||||
// Year = evt.Series.Metadata.ReleaseYear,
|
||||
// ReviewBody = evt.ReviewBody,
|
||||
// ReviewTitle = evt.ReviewTitle
|
||||
// }));
|
||||
|
||||
progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter,
|
||||
totalProgress, evt => Task.FromResult(new ScrobbleDto()
|
||||
{
|
||||
@ -766,7 +755,22 @@ public class ScrobblingService : IScrobblingService
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_tokenService.HasTokenExpired(evt.AppUser.AniListAccessToken))
|
||||
{
|
||||
_unitOfWork.ScrobbleRepository.Attach(new ScrobbleError()
|
||||
{
|
||||
Comment = "AniList token has expired and needs rotating. Scrobbles wont work until then",
|
||||
Details = $"User: {evt.AppUser.UserName}",
|
||||
LibraryId = evt.LibraryId,
|
||||
SeriesId = evt.SeriesId
|
||||
});
|
||||
await _unitOfWork.CommitAsync();
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = await SetAndCheckRateLimit(userRateLimits, evt.AppUser, license.Value);
|
||||
userRateLimits[evt.AppUserId] = count;
|
||||
if (count == 0)
|
||||
{
|
||||
if (usersToScrobble == 1) break;
|
||||
@ -786,6 +790,14 @@ public class ScrobblingService : IScrobblingService
|
||||
// If a flurl exception occured, the API is likely down. Kill processing
|
||||
throw;
|
||||
}
|
||||
catch (KavitaException ex)
|
||||
{
|
||||
if (ex.Message.Contains("Access token is invalid"))
|
||||
{
|
||||
_logger.LogCritical("Access Token for UserId: {UserId} needs to be rotated to continue scrobbling", evt.AppUser.Id);
|
||||
return progressCounter;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow as it's already been handled in PostScrobbleUpdate */
|
||||
|
@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.SeriesDetail;
|
||||
@ -11,6 +14,7 @@ using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services.Plus;
|
||||
using EasyCaching.Core;
|
||||
using Flurl.Http;
|
||||
using HtmlAgilityPack;
|
||||
using Kavita.Common;
|
||||
@ -48,18 +52,98 @@ public class ReviewService : IReviewService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReviewService> _logger;
|
||||
private readonly ILicenseService _licenseService;
|
||||
private readonly IEasyCachingProvider _cacheProvider;
|
||||
public const string CacheKey = "review_";
|
||||
|
||||
|
||||
public ReviewService(IUnitOfWork unitOfWork, ILogger<ReviewService> logger)
|
||||
public ReviewService(IUnitOfWork unitOfWork, ILogger<ReviewService> logger, ILicenseService licenseService,
|
||||
IEasyCachingProviderFactory cachingProviderFactory)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_licenseService = licenseService;
|
||||
|
||||
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
|
||||
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UserReviewDto>> GetReviewsForSeries(int userId, int seriesId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
if (user == null) return ImmutableList<UserReviewDto>.Empty;
|
||||
var userRatings = (await _unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, userId))
|
||||
.Where(r => !string.IsNullOrEmpty(r.Body))
|
||||
.OrderByDescending(review => review.Username.Equals(user.UserName) ? 1 : 0)
|
||||
.ToList();
|
||||
|
||||
if (!await _licenseService.HasActiveLicense())
|
||||
{
|
||||
return userRatings;
|
||||
}
|
||||
|
||||
var cacheKey = CacheKey + seriesId;
|
||||
IList<UserReviewDto> externalReviews;
|
||||
|
||||
var result = await _cacheProvider.GetAsync<IEnumerable<UserReviewDto>>(cacheKey);
|
||||
if (result.HasValue)
|
||||
{
|
||||
externalReviews = result.Value.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var reviews = (await GetExternalReviews(userId, seriesId)).ToList();
|
||||
externalReviews = SelectSpectrumOfReviews(reviews);
|
||||
|
||||
await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10));
|
||||
_logger.LogDebug("Caching external reviews for {Key}", cacheKey);
|
||||
}
|
||||
|
||||
|
||||
// Fetch external reviews and splice them in
|
||||
userRatings.AddRange(externalReviews);
|
||||
|
||||
return userRatings;
|
||||
}
|
||||
|
||||
private static IList<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
|
||||
{
|
||||
IList<UserReviewDto> externalReviews;
|
||||
var totalReviews = reviews.Count;
|
||||
|
||||
if (totalReviews > 10)
|
||||
{
|
||||
var stepSize = Math.Max((totalReviews - 4) / 8, 1);
|
||||
|
||||
var selectedReviews = new List<UserReviewDto>()
|
||||
{
|
||||
reviews[0],
|
||||
reviews[1],
|
||||
};
|
||||
for (var i = 2; i < totalReviews - 2; i += stepSize)
|
||||
{
|
||||
selectedReviews.Add(reviews[i]);
|
||||
|
||||
if (selectedReviews.Count >= 8)
|
||||
break;
|
||||
}
|
||||
|
||||
selectedReviews.Add(reviews[totalReviews - 2]);
|
||||
selectedReviews.Add(reviews[totalReviews - 1]);
|
||||
|
||||
externalReviews = selectedReviews;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalReviews = reviews;
|
||||
}
|
||||
|
||||
return externalReviews;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<UserReviewDto>> GetExternalReviews(int userId, int seriesId)
|
||||
{
|
||||
var series =
|
||||
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
||||
|
@ -19,6 +19,7 @@ public interface ITaskScheduler
|
||||
Task ScheduleTasks();
|
||||
Task ScheduleStatsTasks();
|
||||
void ScheduleUpdaterTasks();
|
||||
Task ScheduleKavitaPlusTasks();
|
||||
void ScanFolder(string folderPath, TimeSpan delay);
|
||||
void ScanFolder(string folderPath);
|
||||
void ScanLibrary(int libraryId, bool force = false);
|
||||
@ -72,7 +73,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
public const string LicenseCheck = "license-check";
|
||||
|
||||
private static readonly ImmutableArray<string> ScanTasks =
|
||||
ImmutableArray.Create("ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries");
|
||||
["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"];
|
||||
|
||||
private static readonly Random Rnd = new Random();
|
||||
|
||||
@ -143,11 +144,22 @@ public class TaskScheduler : ITaskScheduler
|
||||
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions);
|
||||
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions);
|
||||
|
||||
await ScheduleKavitaPlusTasks();
|
||||
}
|
||||
|
||||
public async Task ScheduleKavitaPlusTasks()
|
||||
{
|
||||
// KavitaPlus based (needs license check)
|
||||
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||
if (!await _licenseService.HasActiveSubscription(license))
|
||||
{
|
||||
|
||||
return;
|
||||
}
|
||||
RecurringJob.AddOrUpdate(CheckScrobblingTokens, () => _scrobblingService.CheckExternalAccessTokens(), Cron.Daily, RecurringJobOptions);
|
||||
BackgroundJob.Enqueue(() => _scrobblingService.CheckExternalAccessTokens()); // We also kick off an immediate check on startup
|
||||
RecurringJob.AddOrUpdate(LicenseCheck, () => _licenseService.ValidateLicenseStatus(), LicenseService.Cron, RecurringJobOptions);
|
||||
BackgroundJob.Enqueue(() => _licenseService.ValidateLicenseStatus());
|
||||
RecurringJob.AddOrUpdate(LicenseCheck, () => _licenseService.HasActiveLicense(true), LicenseService.Cron, RecurringJobOptions);
|
||||
BackgroundJob.Enqueue(() => _licenseService.HasActiveLicense(true));
|
||||
|
||||
// KavitaPlus Scrobbling (every 4 hours)
|
||||
RecurringJob.AddOrUpdate(ProcessScrobblingEvents, () => _scrobblingService.ProcessUpdatesSinceLastSync(), "0 */4 * * *", RecurringJobOptions);
|
||||
|
@ -0,0 +1,9 @@
|
||||
import {Recommendation} from "./recommendation";
|
||||
import {UserReview} from "../../_single-module/review-card/user-review";
|
||||
import {Rating} from "../rating";
|
||||
|
||||
export interface SeriesDetailPlus {
|
||||
recommendations: Recommendation;
|
||||
reviews: Array<UserReview>;
|
||||
ratings: Array<Rating>;
|
||||
}
|
@ -237,6 +237,13 @@ export class ActionFactoryService {
|
||||
requiresAdmin: true,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Delete,
|
||||
title: 'delete',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -135,6 +135,26 @@ export class ActionService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteLibrary(library: Partial<Library>, callback?: LibraryActionCallback) {
|
||||
if (!library.hasOwnProperty('id') || library.id === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await this.confirmService.alert(translate('toasts.confirm-library-delete'))) {
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.libraryService.delete(library?.id).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.info(translate('toasts.library-deleted', {name: library.name}));
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a series as read; updates the series pagesRead
|
||||
* @param series Series, must have id and name populated
|
||||
|
@ -16,6 +16,7 @@ import {SortField} from "../_models/metadata/series-filter";
|
||||
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
|
||||
import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -25,7 +26,11 @@ export class MetadataService {
|
||||
baseUrl = environment.apiUrl;
|
||||
private validLanguages: Array<Language> = [];
|
||||
|
||||
constructor(private httpClient: HttpClient, private router: Router) { }
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getSeriesMetadataFromPlus(seriesId: number) {
|
||||
return this.httpClient.get<SeriesDetailPlus | null>(this.baseUrl + 'metadata/series-detail-plus?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
getAllAgeRatings(libraries?: Array<number>) {
|
||||
let method = 'metadata/age-ratings'
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
|
||||
import {ScrobblingService} from "../../_services/scrobbling.service";
|
||||
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
|
||||
import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe";
|
||||
@ -11,10 +11,11 @@ import {debounceTime, take} from "rxjs/operators";
|
||||
import {PaginatedResult, Pagination} from "../../_models/pagination";
|
||||
import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-scrobble-history',
|
||||
@ -26,9 +27,11 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
})
|
||||
export class UserScrobbleHistoryComponent implements OnInit {
|
||||
|
||||
private readonly scrobbleService = inject(ScrobblingService);
|
||||
private readonly scrobblingService = inject(ScrobblingService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
protected readonly ScrobbleEventType = ScrobbleEventType;
|
||||
|
||||
pagination: Pagination | undefined;
|
||||
events: Array<ScrobbleEvent> = [];
|
||||
@ -36,11 +39,16 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
||||
'filter': new FormControl('', [])
|
||||
});
|
||||
|
||||
get ScrobbleEventType() { return ScrobbleEventType; }
|
||||
|
||||
ngOnInit() {
|
||||
this.loadPage({column: 'createdUtc', direction: 'desc'});
|
||||
|
||||
this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => {
|
||||
if (hasExpired) {
|
||||
this.toastr.error(translate('toasts.anilist-token-expired'));
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => {
|
||||
this.loadPage();
|
||||
})
|
||||
@ -73,7 +81,7 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
||||
const field = this.mapSortColumnField(sortEvent?.column);
|
||||
const query = this.formGroup.get('filter')?.value;
|
||||
|
||||
this.scrobbleService.getScrobbleEvents({query, field, isDescending}, page, pageSize)
|
||||
this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize)
|
||||
.pipe(take(1))
|
||||
.subscribe((result: PaginatedResult<ScrobbleEvent[]>) => {
|
||||
this.events = result.result;
|
||||
|
@ -14,8 +14,6 @@ export interface DirectoryPickerResult {
|
||||
folderPath: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-directory-picker',
|
||||
templateUrl: './directory-picker.component.html',
|
||||
|
@ -24,6 +24,7 @@ import { RouterLink } from '@angular/router';
|
||||
import { NgFor, NgIf } from '@angular/common';
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-library',
|
||||
@ -35,6 +36,15 @@ import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||
})
|
||||
export class ManageLibraryComponent implements OnInit {
|
||||
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly hubService = inject(MessageHubService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
libraries: Library[] = [];
|
||||
loading = false;
|
||||
/**
|
||||
@ -42,11 +52,8 @@ export class ManageLibraryComponent implements OnInit {
|
||||
*/
|
||||
deletionInProgress: boolean = false;
|
||||
libraryTrackBy = (index: number, item: Library) => `${item.name}_${item.lastScanned}_${item.type}_${item.folders.length}`;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(private modalService: NgbModal, private libraryService: LibraryService,
|
||||
private toastr: ToastrService, private confirmService: ConfirmService,
|
||||
private hubService: MessageHubService, private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.getLibraries();
|
||||
|
@ -19,7 +19,8 @@
|
||||
<app-card-item [entity]="item" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
|
||||
[suppressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
|
||||
[actions]="actions"
|
||||
[selected]="bulkSelectionService.isCardSelected('bookmark', position)" (selection)="bulkSelectionService.handleCardSelection('bookmark', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('bookmark', position)"
|
||||
(selection)="bulkSelectionService.handleCardSelection('bookmark', position, series.length, $event)"
|
||||
></app-card-item>
|
||||
</ng-template>
|
||||
|
||||
|
@ -9,11 +9,11 @@ import {
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs';
|
||||
import {take} from 'rxjs';
|
||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import {DownloadService} from 'src/app/shared/_services/download.service';
|
||||
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
|
||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
|
||||
@ -25,7 +25,6 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { JumpbarService } from 'src/app/_services/jumpbar.service';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import {DecimalPipe, NgIf} from '@angular/common';
|
||||
import { CardItemComponent } from '../../../cards/card-item/card-item.component';
|
||||
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
|
||||
@ -45,11 +44,25 @@ import {Title} from "@angular/platform-browser";
|
||||
})
|
||||
export class BookmarksComponent implements OnInit {
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
private readonly titleService = inject(Title);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
public readonly imageService = inject(ImageService);
|
||||
|
||||
bookmarks: Array<PageBookmark> = [];
|
||||
series: Array<Series> = [];
|
||||
loadingBookmarks: boolean = false;
|
||||
seriesIds: {[id: number]: number} = {};
|
||||
downloadingSeries: {[id: number]: boolean} = {};
|
||||
clearingSeries: {[id: number]: boolean} = {};
|
||||
actions: ActionItem<Series>[] = [];
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
@ -64,16 +77,7 @@ export class BookmarksComponent implements OnInit {
|
||||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
constructor(private readerService: ReaderService,
|
||||
private downloadService: DownloadService, private toastr: ToastrService,
|
||||
private confirmService: ConfirmService, public bulkSelectionService: BulkSelectionService,
|
||||
public imageService: ImageService, private actionFactoryService: ActionFactoryService,
|
||||
private router: Router, private readonly cdRef: ChangeDetectorRef,
|
||||
private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute,
|
||||
private jumpbarService: JumpbarService, private titleService: Title) {
|
||||
|
||||
constructor() {
|
||||
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
|
||||
this.filter = filter;
|
||||
|
||||
@ -160,8 +164,11 @@ export class BookmarksComponent implements OnInit {
|
||||
this.readerService.getAllBookmarks(this.filter).pipe(take(1)).subscribe(bookmarks => {
|
||||
this.bookmarks = bookmarks;
|
||||
this.bookmarks.forEach(bmk => {
|
||||
this.downloadingSeries[bmk.seriesId] = false;
|
||||
this.clearingSeries[bmk.seriesId] = false;
|
||||
if (!this.seriesIds.hasOwnProperty(bmk.seriesId)) {
|
||||
this.seriesIds[bmk.seriesId] = 0;
|
||||
}
|
||||
this.seriesIds[bmk.seriesId] += 1;
|
||||
});
|
||||
|
||||
const distinctSeriesMap = new Map();
|
||||
@ -199,14 +206,7 @@ export class BookmarksComponent implements OnInit {
|
||||
}
|
||||
|
||||
downloadBookmarks(series: Series) {
|
||||
this.downloadingSeries[series.id] = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id), (d) => {
|
||||
if (!d) {
|
||||
this.downloadingSeries[series.id] = false;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id));
|
||||
}
|
||||
|
||||
updateFilter(data: FilterEvent) {
|
||||
|
@ -108,45 +108,52 @@
|
||||
<li [ngbNavItem]="tabs[TabID.Files]" [disabled]="(isAdmin$ | async) === false">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Files].title)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
|
||||
@if (!utilityService.isChapter(data)) {
|
||||
<h4>{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
|
||||
}
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex my-4" *ngFor="let chapter of chapters">
|
||||
<!-- TODO: Localize title -->
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{utilityService.formatChapterName(libraryType, true, false)}} {{formatChapterNumber(chapter)}}">
|
||||
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
|
||||
</a>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mt-0 mb-1">
|
||||
<span>
|
||||
<span>
|
||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
|
||||
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
||||
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||
</ng-container>
|
||||
</span>
|
||||
<span class="badge bg-primary rounded-pill ms-1">
|
||||
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
|
||||
<span *ngIf="chapter.pagesRead === 0">{{t('unread') | uppercase}}</span>
|
||||
<span *ngIf="chapter.pagesRead === chapter.pages">{{t('read') | uppercase}}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span>
|
||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
|
||||
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
||||
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||
</ng-container>
|
||||
</span>
|
||||
<span class="badge bg-primary rounded-pill ms-1">
|
||||
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
|
||||
<span *ngIf="chapter.pagesRead === 0">{{t('unread') | uppercase}}</span>
|
||||
<span *ngIf="chapter.pagesRead === chapter.pages">{{t('read') | uppercase}}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ng-template #specialHeader>{{t('files')}}</ng-template>
|
||||
</h5>
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
{{t('pages')}} {{file.pages | number:''}}
|
||||
@for (file of chapter.files; track file.id) {
|
||||
<li class="list-group-item no-hover">
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
{{t('pages')}} {{file.pages | number:''}}
|
||||
</div>
|
||||
@if (data.hasOwnProperty('created')) {
|
||||
<div class="col">
|
||||
{{t('added')}} {{file.created | date: 'short' | defaultDate}}
|
||||
</div>
|
||||
}
|
||||
<div class="col">
|
||||
{{t('size')}} {{file.bytes | bytes}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col" *ngIf="data.hasOwnProperty('created')">
|
||||
{{t('added')}} {{file.created | date: 'short' | defaultDate}}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{t('size')}} {{file.bytes | bytes}}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
@ -68,12 +68,17 @@ enum TabID {
|
||||
})
|
||||
export class CardDetailDrawerComponent implements OnInit {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
protected readonly LibraryType = LibraryType;
|
||||
protected readonly TabID = TabID;
|
||||
|
||||
@Input() parentName = '';
|
||||
@Input() seriesId: number = 0;
|
||||
@Input() libraryId: number = 0;
|
||||
@Input({required: true}) data!: Volume | Chapter;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
|
||||
/**
|
||||
* If this is a volume, this will be first chapter for said volume.
|
||||
@ -104,26 +109,13 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
];
|
||||
active = this.tabs[0];
|
||||
|
||||
chapterMetadata!: ChapterMetadata;
|
||||
chapterMetadata: ChapterMetadata | undefined;
|
||||
summary: string = '';
|
||||
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
get MangaFormat() {
|
||||
return MangaFormat;
|
||||
}
|
||||
|
||||
get Breakpoint() {
|
||||
return Breakpoint;
|
||||
}
|
||||
|
||||
get LibraryType() {
|
||||
return LibraryType;
|
||||
}
|
||||
|
||||
get TabID() {
|
||||
return TabID;
|
||||
}
|
||||
|
||||
constructor(public utilityService: UtilityService,
|
||||
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
|
||||
@ -160,7 +152,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
|
||||
.filter(item => item.action !== Action.Edit);
|
||||
this.chapterActions.push({title: 'Read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []});
|
||||
this.chapterActions.push({title: 'read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []});
|
||||
if (this.isChapter) {
|
||||
const chapter = this.utilityService.asChapter(this.data);
|
||||
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter);
|
||||
|
@ -277,26 +277,9 @@ export class CardItemComponent implements OnInit {
|
||||
});
|
||||
|
||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
||||
if(this.utilityService.isSeries(this.entity)) {
|
||||
return events.find(e => e.entityType === 'series' && e.id == this.entity.id
|
||||
&& e.subTitle === this.downloadService.downloadSubtitle('series', (this.entity as Series))) || null;
|
||||
}
|
||||
if(this.utilityService.isVolume(this.entity)) {
|
||||
return events.find(e => e.entityType === 'volume' && e.id == this.entity.id
|
||||
&& e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null;
|
||||
}
|
||||
if(this.utilityService.isChapter(this.entity)) {
|
||||
return events.find(e => e.entityType === 'chapter' && e.id == this.entity.id
|
||||
&& e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null;
|
||||
}
|
||||
// Is PageBookmark[]
|
||||
if(this.entity.hasOwnProperty('length')) {
|
||||
return events.find(e => e.entityType === 'bookmark'
|
||||
&& e.subTitle === this.downloadService.downloadSubtitle('bookmark', [(this.entity as PageBookmark)])) || null;
|
||||
}
|
||||
return null;
|
||||
console.log('Card Item download obv called for entity: ', this.entity);
|
||||
return this.downloadService.mapToEntityType(events, this.entity);
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,86 +1,98 @@
|
||||
<app-side-nav-companion-bar></app-side-nav-companion-bar>
|
||||
|
||||
<ng-container *transloco="let t; read: 'dashboard'">
|
||||
<ng-container *ngIf="libraries$ | async as libraries">
|
||||
<ng-container *ngIf="libraries.length === 0">
|
||||
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
|
||||
<div *ngIf="isAdmin" class="d-flex justify-content-center">
|
||||
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a></p>
|
||||
</div>
|
||||
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
|
||||
<p>{{t('not-granted')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@if (libraries$ | async; as libraries) {
|
||||
@if (libraries.length === 0) {
|
||||
@if (isAdmin$ | async; as isAdmin) {
|
||||
<div class="mt-3">
|
||||
@if (isAdmin) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a></p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex justify-content-center">
|
||||
<p>{{t('not-granted')}}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<ng-container *ngFor="let stream of streams">
|
||||
<ng-container [ngSwitch]="stream.streamType">
|
||||
<ng-container *ngSwitchCase="StreamType.OnDeck" [ngTemplateOutlet]="onDeck" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
@for(stream of streams; track stream.id) {
|
||||
@switch (stream.streamType) {
|
||||
@case (StreamType.OnDeck) {
|
||||
<ng-container [ngTemplateOutlet]="onDeck" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
}
|
||||
@case (StreamType.RecentlyUpdated) {
|
||||
<ng-container [ngTemplateOutlet]="recentlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
}
|
||||
@case (StreamType.NewlyAdded) {
|
||||
<ng-container [ngTemplateOutlet]="newlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
}
|
||||
@case (StreamType.SmartFilter) {
|
||||
<ng-container [ngTemplateOutlet]="smartFilter" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
}
|
||||
@case (StreamType.MoreInGenre) {
|
||||
<ng-container [ngTemplateOutlet]="moreInGenre" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
<ng-container *ngSwitchCase="StreamType.RecentlyUpdated" [ngTemplateOutlet]="recentlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="StreamType.NewlyAdded" [ngTemplateOutlet]="newlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="StreamType.SmartFilter" [ngTemplateOutlet]="smartFilter" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="StreamType.MoreInGenre" [ngTemplateOutlet]="moreInGenre" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #smartFilter let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
@if(stream.api | async; as data) {
|
||||
<app-carousel-reel [items]="data" [title]="stream.name" (sectionClick)="handleFilterSectionClick(stream)">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId"
|
||||
(reload)="reloadStream(item.id)" (dataChanged)="reloadStream(item.id)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #onDeck let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
<app-carousel-reel [items]="data" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
|
||||
@if(stream.api | async; as data) {
|
||||
<app-carousel-reel [items]="data" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick(StreamId.OnDeck)">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [isOnDeck]="true"
|
||||
(reload)="reloadStream(stream.id)" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #recentlyUpdated let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
|
||||
@if(stream.api | async; as data) {
|
||||
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick(StreamId.RecentlyUpdatedSeries)">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
||||
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #newlyUpdated let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
<app-carousel-reel [items]="data" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick('newly added series')">
|
||||
@if(stream.api | async; as data) {
|
||||
<app-carousel-reel [items]="data" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick(StreamId.NewlyAddedSeries)">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #moreInGenre let-stream: DashboardStream>
|
||||
<ng-container *ngIf="(stream.api | async) as data">
|
||||
<app-carousel-reel [items]="data" [title]="t('more-in-genre-title', {genre: genre?.title})" (sectionClick)="handleSectionClick('more in genre')">
|
||||
@if(stream.api | async; as data) {
|
||||
<app-carousel-reel [items]="data" [title]="t('more-in-genre-title', {genre: genre?.title})" (sectionClick)="handleSectionClick(StreamId.MoreInGenre)">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<app-loading [loading]="isLoadingDashboard || (streamCount !== streamsLoaded)"></app-loading>
|
||||
</ng-container>
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {Router, RouterLink} from '@angular/router';
|
||||
import {Observable, of, ReplaySubject, Subject, switchMap} from 'rxjs';
|
||||
@ -16,7 +16,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {CardItemComponent} from '../../cards/card-item/card-item.component';
|
||||
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
|
||||
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
|
||||
import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from '@angular/common';
|
||||
import {AsyncPipe, NgForOf, NgTemplateOutlet} from '@angular/common';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
@ -30,6 +30,16 @@ import {Genre} from "../../_models/metadata/genre";
|
||||
import {DashboardStream} from "../../_models/dashboard/dashboard-stream";
|
||||
import {StreamType} from "../../_models/dashboard/stream-type.enum";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
|
||||
enum StreamId {
|
||||
OnDeck,
|
||||
RecentlyUpdatedSeries,
|
||||
NewlyAddedSeries,
|
||||
MoreInGenre,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@ -37,8 +47,8 @@ import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
styleUrls: ['./dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent,
|
||||
CardItemComponent, AsyncPipe, TranslocoDirective, NgSwitchCase, NgSwitch, NgForOf, NgTemplateOutlet, LoadingComponent],
|
||||
imports: [SideNavCompanionBarComponent, RouterLink, CarouselReelComponent, SeriesCardComponent,
|
||||
CardItemComponent, AsyncPipe, TranslocoDirective, NgForOf, NgTemplateOutlet, LoadingComponent],
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
|
||||
@ -55,6 +65,8 @@ export class DashboardComponent implements OnInit {
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly dashboardService = inject(DashboardService);
|
||||
private readonly scrobblingService = inject(ScrobblingService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
libraries$: Observable<Library[]> = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef))
|
||||
isLoadingDashboard = true;
|
||||
@ -73,6 +85,7 @@ export class DashboardComponent implements OnInit {
|
||||
*/
|
||||
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
|
||||
protected readonly StreamType = StreamType;
|
||||
protected readonly StreamId = StreamId;
|
||||
|
||||
constructor() {
|
||||
this.loadDashboard();
|
||||
@ -105,6 +118,14 @@ export class DashboardComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
|
||||
this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => {
|
||||
if (hasExpired) {
|
||||
this.toastr.error(translate('toasts.anilist-token-expired'));
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
||||
this.isAdmin$ = this.accountService.currentUser$.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(user => (user && this.accountService.hasAdminRole(user)) || false),
|
||||
@ -186,18 +207,18 @@ export class DashboardComponent implements OnInit {
|
||||
await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded);
|
||||
}
|
||||
|
||||
handleSectionClick(sectionTitle: string) {
|
||||
if (sectionTitle.toLowerCase() === 'recently updated series') {
|
||||
handleSectionClick(streamId: StreamId) {
|
||||
if (streamId === StreamId.RecentlyUpdatedSeries) {
|
||||
const params: any = {};
|
||||
params['page'] = 1;
|
||||
params['title'] = 'Recently Updated';
|
||||
params['title'] = translate('dashboard.recently-updated-title');
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
if (filter.sortOptions) {
|
||||
filter.sortOptions.sortField = SortField.LastChapterAdded;
|
||||
filter.sortOptions.isAscending = false;
|
||||
}
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
||||
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||
} else if (streamId === StreamId.OnDeck) {
|
||||
const params: any = {};
|
||||
params['page'] = 1;
|
||||
params['title'] = translate('dashboard.on-deck-title');
|
||||
@ -210,7 +231,7 @@ export class DashboardComponent implements OnInit {
|
||||
filter.sortOptions.isAscending = false;
|
||||
}
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
||||
} else if (sectionTitle.toLowerCase() === 'newly added series') {
|
||||
} else if (streamId === StreamId.NewlyAddedSeries) {
|
||||
const params: any = {};
|
||||
params['page'] = 1;
|
||||
params['title'] = translate('dashboard.recently-added-title');
|
||||
@ -220,10 +241,10 @@ export class DashboardComponent implements OnInit {
|
||||
filter.sortOptions.isAscending = false;
|
||||
}
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
||||
} else if (sectionTitle.toLowerCase() === 'more in genre') {
|
||||
} else if (streamId === StreamId.MoreInGenre) {
|
||||
const params: any = {};
|
||||
params['page'] = 1;
|
||||
params['title'] = translate('more-in-genre-title', {genre: this.genre?.title});
|
||||
params['title'] = translate('dashboard.more-in-genre-title', {genre: this.genre?.title});
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains});
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
||||
|
@ -132,6 +132,9 @@
|
||||
</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
@if(activeDownloads.length > 1) {
|
||||
<li class="list-group-item dark-menu-item">{{activeDownloads.length}} downloads in Queue</li>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
|
||||
|
@ -61,16 +61,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
||||
|
||||
activeEvents: number = 0;
|
||||
|
||||
debugMode: boolean = false;
|
||||
debugMode: boolean = true;
|
||||
|
||||
protected readonly EVENTS = EVENTS;
|
||||
|
||||
get EVENTS() {
|
||||
return EVENTS;
|
||||
}
|
||||
public readonly downloadService = inject(DownloadService);
|
||||
|
||||
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
|
||||
private accountService: AccountService, private confirmService: ConfirmService,
|
||||
private readonly cdRef: ChangeDetectorRef, public downloadService: DownloadService) {
|
||||
private readonly cdRef: ChangeDetectorRef) {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Component, DestroyRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
@ -20,6 +20,7 @@ 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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
|
||||
@Component({
|
||||
selector: 'app-external-rating',
|
||||
@ -31,28 +32,31 @@ import {ImageComponent} from "../../../shared/image/image.component";
|
||||
encapsulation: ViewEncapsulation.None
|
||||
})
|
||||
export class ExternalRatingComponent implements OnInit {
|
||||
@Input({required: true}) seriesId!: number;
|
||||
@Input({required: true}) userRating!: number;
|
||||
@Input({required: true}) hasUserRated!: boolean;
|
||||
@Input({required: true}) libraryType!: LibraryType;
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
public readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
|
||||
@Input({required: true}) seriesId!: number;
|
||||
@Input({required: true}) userRating!: number;
|
||||
@Input({required: true}) hasUserRated!: boolean;
|
||||
@Input({required: true}) libraryType!: LibraryType;
|
||||
|
||||
|
||||
ratings: Array<Rating> = [];
|
||||
isLoading: boolean = false;
|
||||
overallRating: number = -1;
|
||||
|
||||
starColor = this.themeService.getCssVariable('--rating-star-color');
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore);
|
||||
|
||||
this.accountService.hasValidLicense$.subscribe((res) => {
|
||||
this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res) => {
|
||||
if (!res) return;
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
@ -74,6 +78,4 @@ export class ExternalRatingComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
}
|
||||
|
@ -105,22 +105,32 @@
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block" *ngIf="isAdmin || hasDownloadingRole">
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="downloadInProgress">
|
||||
<ng-container *ngIf="downloadInProgress; else notDownloading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">{{t('downloading-status')}}</span>
|
||||
</ng-container>
|
||||
<ng-template #notDownloading>
|
||||
@if (download$ | async; as download) {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null">
|
||||
<ng-container *ngIf="download !== null; else notDownloading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">{{t('downloading-status')}}</span>
|
||||
</ng-container>
|
||||
<ng-template #notDownloading>
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')">
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="seriesMetadata" class="mt-2">
|
||||
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
||||
[libraryType]="libraryType"
|
||||
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
||||
</div>
|
||||
|
||||
@if (seriesMetadata) {
|
||||
<div class="mt-2">
|
||||
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
||||
[libraryType]="libraryType"
|
||||
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata?.summary?.length === 0}">
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
AsyncPipe,
|
||||
DecimalPipe,
|
||||
DOCUMENT,
|
||||
NgClass,
|
||||
@ -42,13 +43,13 @@ import {
|
||||
NgbTooltip
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {catchError, forkJoin, of} from 'rxjs';
|
||||
import {take} from 'rxjs/operators';
|
||||
import {catchError, forkJoin, Observable, of} from 'rxjs';
|
||||
import {filter, map, take} from 'rxjs/operators';
|
||||
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
||||
import {CardDetailDrawerComponent} from 'src/app/cards/card-detail-drawer/card-detail-drawer.component';
|
||||
import {EditSeriesModalComponent} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
|
||||
import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component';
|
||||
import {DownloadService} from 'src/app/shared/_services/download.service';
|
||||
import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service';
|
||||
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {Chapter} from 'src/app/_models/chapter';
|
||||
import {Device} from 'src/app/_models/device/device';
|
||||
@ -105,6 +106,7 @@ import {PublicationStatus} from "../../../_models/metadata/publication-status";
|
||||
import {NextExpectedChapter} from "../../../_models/series-detail/next-expected-chapter";
|
||||
import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-expected-card.component";
|
||||
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
||||
import {MetadataService} from "../../../_services/metadata.service";
|
||||
|
||||
interface RelatedSeriesPair {
|
||||
series: Series;
|
||||
@ -126,19 +128,22 @@ interface StoryLineItem {
|
||||
isChapter: boolean;
|
||||
}
|
||||
|
||||
const KavitaPlusSupportedLibraryTypes = [LibraryType.Manga, LibraryType.Book];
|
||||
|
||||
@Component({
|
||||
selector: 'app-series-detail',
|
||||
templateUrl: './series-detail.component.html',
|
||||
styleUrls: ['./series-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase, NextExpectedCardComponent, NgClass, NgOptimizedImage, ProviderImagePipe]
|
||||
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase, NextExpectedCardComponent, NgClass, NgOptimizedImage, ProviderImagePipe, AsyncPipe]
|
||||
})
|
||||
export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
@ -261,6 +266,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
user: User | undefined;
|
||||
|
||||
/**
|
||||
* This is the download we get from download service.
|
||||
*/
|
||||
download$: Observable<DownloadEvent | null> | null = null;
|
||||
|
||||
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
||||
if (this.series === undefined) {
|
||||
return;
|
||||
@ -368,6 +378,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup the download in progress
|
||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
||||
return this.downloadService.mapToEntityType(events, this.series);
|
||||
}));
|
||||
|
||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
|
||||
if (event.event === EVENTS.SeriesRemoved) {
|
||||
const seriesRemovedEvent = event.payload as SeriesRemovedEvent;
|
||||
@ -545,7 +560,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return;
|
||||
this.seriesService.getNextExpectedChapterDate(seriesId).subscribe(date => {
|
||||
if (date == null || date.expectedDate === null) return;
|
||||
if (date == null || date.expectedDate === null) {
|
||||
if (this.nextExpectedChapter !== undefined) {
|
||||
// Clear out the data so the card removes
|
||||
this.nextExpectedChapter = undefined;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.nextExpectedChapter = date;
|
||||
this.cdRef.markForCheck();
|
||||
@ -563,6 +585,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
});
|
||||
this.setContinuePoint();
|
||||
|
||||
if (KavitaPlusSupportedLibraryTypes.includes(this.libraryType) && loadExternal) {
|
||||
this.loadPlusMetadata(this.seriesId);
|
||||
}
|
||||
|
||||
forkJoin({
|
||||
libType: this.libraryService.getLibraryType(this.libraryId),
|
||||
series: this.seriesService.getSeries(seriesId)
|
||||
@ -570,10 +596,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.libraryType = results.libType;
|
||||
this.series = results.series;
|
||||
|
||||
if (this.libraryType !== LibraryType.Comic && loadExternal) {
|
||||
this.loadReviews(true);
|
||||
}
|
||||
|
||||
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
|
||||
|
||||
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
|
||||
@ -670,23 +692,37 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
}
|
||||
}
|
||||
|
||||
loadRecommendations() {
|
||||
this.seriesService.getRecommendationsForSeries(this.seriesId).subscribe(rec => {
|
||||
rec.ownedSeries.map(r => {
|
||||
// loadRecommendations() {
|
||||
// this.seriesService.getRecommendationsForSeries(this.seriesId).subscribe(rec => {
|
||||
// rec.ownedSeries.map(r => {
|
||||
// this.seriesService.getMetadata(r.id).subscribe(m => r.summary = m.summary);
|
||||
// });
|
||||
// this.combinedRecs = [...rec.ownedSeries, ...rec.externalSeries];
|
||||
// this.hasRecommendations = this.combinedRecs.length > 0;
|
||||
// this.cdRef.markForCheck();
|
||||
// });
|
||||
// }
|
||||
|
||||
loadPlusMetadata(seriesId: number) {
|
||||
this.metadataService.getSeriesMetadataFromPlus(seriesId).subscribe(data => {
|
||||
if (data === null) return;
|
||||
|
||||
// Reviews
|
||||
this.reviews = [...data.reviews];
|
||||
|
||||
// Recommendations
|
||||
data.recommendations.ownedSeries.map(r => {
|
||||
this.seriesService.getMetadata(r.id).subscribe(m => r.summary = m.summary);
|
||||
});
|
||||
this.combinedRecs = [...rec.ownedSeries, ...rec.externalSeries];
|
||||
this.combinedRecs = [...data.recommendations.ownedSeries, ...data.recommendations.externalSeries];
|
||||
this.hasRecommendations = this.combinedRecs.length > 0;
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
loadReviews(loadRecs: boolean = false) {
|
||||
loadReviews() {
|
||||
this.seriesService.getReviews(this.seriesId).subscribe(reviews => {
|
||||
this.reviews = [...reviews];
|
||||
if (loadRecs) {
|
||||
this.loadRecommendations(); // We do this as first load will spam 3 calls on API layer
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
@ -829,7 +865,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
modalRef.componentInstance.series = this.series;
|
||||
modalRef.closed.subscribe((closeResult: {success: boolean}) => {
|
||||
if (closeResult.success) {
|
||||
this.loadReviews();
|
||||
this.loadReviews(); // TODO: Ensure reviews get updated here
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
<app-metadata-detail [tags]="['']" [libraryId]="series.libraryId" heading="Ratings">
|
||||
<app-metadata-detail [tags]="['']" [libraryId]="series.libraryId" [heading]="t('rating-title')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<app-external-rating [seriesId]="series.id" [userRating]="series.userRating" [hasUserRated]="series.hasUserRated" [libraryType]="libraryType"></app-external-rating>
|
||||
</ng-template>
|
||||
|
@ -48,6 +48,18 @@ import {ImageComponent} from "../../../shared/image/image.component";
|
||||
})
|
||||
export class SeriesMetadataDetailComponent implements OnChanges {
|
||||
|
||||
protected readonly imageService = inject(ImageService);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
|
||||
protected readonly FilterField = FilterField;
|
||||
protected readonly LibraryType = LibraryType;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
|
||||
@Input({required: true}) seriesMetadata!: SeriesMetadata;
|
||||
@Input({required: true}) libraryType!: LibraryType;
|
||||
@Input() hasReadingProgress: boolean = false;
|
||||
@ -60,23 +72,11 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
||||
isCollapsed: boolean = true;
|
||||
hasExtendedProperties: boolean = false;
|
||||
|
||||
protected readonly imageService = inject(ImageService);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
|
||||
/**
|
||||
* Html representation of Series Summary
|
||||
*/
|
||||
seriesSummary: string = '';
|
||||
|
||||
protected FilterField = FilterField;
|
||||
protected LibraryType = LibraryType;
|
||||
protected MangaFormat = MangaFormat;
|
||||
protected TagBadgeCursor = TagBadgeCursor;
|
||||
|
||||
get WebLinks() {
|
||||
if (this.seriesMetadata?.webLinks === '') return [];
|
||||
return this.seriesMetadata?.webLinks.split(',') || [];
|
||||
@ -121,6 +121,4 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
||||
navigate(basePage: string, id: number) {
|
||||
this.router.navigate([basePage, id]);
|
||||
}
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
tap,
|
||||
finalize,
|
||||
of,
|
||||
filter,
|
||||
filter, Subject,
|
||||
} from 'rxjs';
|
||||
import { download, Download } from '../_models/download';
|
||||
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
|
||||
@ -22,6 +22,10 @@ import { BytesPipe } from 'src/app/_pipes/bytes.pipe';
|
||||
import {translate} from "@ngneat/transloco";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {SAVER, Saver} from "../../_providers/saver.provider";
|
||||
import {UtilityService} from "./utility.service";
|
||||
import {CollectionTag} from "../../_models/collection-tag";
|
||||
import {RecentlyAddedItem} from "../../_models/recently-added-item";
|
||||
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
|
||||
|
||||
export const DEBOUNCE_TIME = 100;
|
||||
|
||||
@ -55,6 +59,7 @@ export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' |
|
||||
*/
|
||||
export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | undefined;
|
||||
|
||||
export type QueueableDownloadType = Chapter | Volume;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -68,14 +73,33 @@ export class DownloadService {
|
||||
public SIZE_WARNING = 104_857_600;
|
||||
|
||||
private downloadsSource: BehaviorSubject<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
|
||||
/**
|
||||
* Active Downloads
|
||||
*/
|
||||
public activeDownloads$ = this.downloadsSource.asObservable();
|
||||
|
||||
private downloadQueue: BehaviorSubject<QueueableDownloadType[]> = new BehaviorSubject<QueueableDownloadType[]>([]);
|
||||
/**
|
||||
* Queued Downloads
|
||||
*/
|
||||
public queuedDownloads$ = this.downloadQueue.asObservable();
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
|
||||
constructor(@Inject(SAVER) private save: Saver) { }
|
||||
constructor(@Inject(SAVER) private save: Saver) {
|
||||
this.downloadQueue.subscribe((queue) => {
|
||||
if (queue.length > 0) {
|
||||
const entity = queue.shift();
|
||||
console.log('Download Queue shifting entity: ', entity);
|
||||
if (entity === undefined) return;
|
||||
this.processDownload(entity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@ -84,7 +108,7 @@ export class DownloadService {
|
||||
* @param downloadEntity
|
||||
* @returns
|
||||
*/
|
||||
downloadSubtitle(downloadEntityType: DownloadEntityType, downloadEntity: DownloadEntity | undefined) {
|
||||
downloadSubtitle(downloadEntityType: DownloadEntityType | undefined, downloadEntity: DownloadEntity | undefined) {
|
||||
switch (downloadEntityType) {
|
||||
case 'series':
|
||||
return (downloadEntity as Series).name;
|
||||
@ -97,6 +121,7 @@ export class DownloadService {
|
||||
case 'logs':
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -117,10 +142,12 @@ export class DownloadService {
|
||||
case 'volume':
|
||||
sizeCheckCall = this.downloadVolumeSize((entity as Volume).id);
|
||||
downloadCall = this.downloadVolume(entity as Volume);
|
||||
//this.enqueueDownload(entity as Volume);
|
||||
break;
|
||||
case 'chapter':
|
||||
sizeCheckCall = this.downloadChapterSize((entity as Chapter).id);
|
||||
downloadCall = this.downloadChapter(entity as Chapter);
|
||||
//this.enqueueDownload(entity as Chapter);
|
||||
break;
|
||||
case 'bookmark':
|
||||
sizeCheckCall = of(0);
|
||||
@ -145,8 +172,10 @@ export class DownloadService {
|
||||
})
|
||||
).pipe(filter(wantsToDownload => {
|
||||
return wantsToDownload;
|
||||
}), switchMap(() => {
|
||||
return downloadCall.pipe(
|
||||
}),
|
||||
filter(_ => downloadCall !== undefined),
|
||||
switchMap(() => {
|
||||
return (downloadCall || of(undefined)).pipe(
|
||||
tap((d) => {
|
||||
if (callback) callback(d);
|
||||
}),
|
||||
@ -187,7 +216,40 @@ export class DownloadService {
|
||||
);
|
||||
}
|
||||
|
||||
private getIdKey(entity: Chapter | Volume) {
|
||||
if (this.utilityService.isVolume(entity)) return 'volumeId';
|
||||
if (this.utilityService.isChapter(entity)) return 'chapterId';
|
||||
if (this.utilityService.isSeries(entity)) return 'seriesId';
|
||||
return 'id';
|
||||
}
|
||||
|
||||
private getDownloadEntityType(entity: Chapter | Volume): DownloadEntityType {
|
||||
if (this.utilityService.isVolume(entity)) return 'volume';
|
||||
if (this.utilityService.isChapter(entity)) return 'chapter';
|
||||
if (this.utilityService.isSeries(entity)) return 'series';
|
||||
return 'logs'; // This is a hack but it will never occur
|
||||
}
|
||||
|
||||
private downloadEntity<T>(entity: Chapter | Volume): Observable<any> {
|
||||
const downloadEntityType = this.getDownloadEntityType(entity);
|
||||
const subtitle = this.downloadSubtitle(downloadEntityType, entity);
|
||||
const idKey = this.getIdKey(entity);
|
||||
const url = `${this.baseUrl}download/${downloadEntityType}?${idKey}=${entity.id}`;
|
||||
|
||||
return this.httpClient.get(url, { observe: 'events', responseType: 'blob', reportProgress: true }).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, decodeURIComponent(filename));
|
||||
}),
|
||||
tap((d) => this.updateDownloadState(d, downloadEntityType, subtitle, entity.id)),
|
||||
finalize(() => this.finalizeDownloadState(downloadEntityType, subtitle))
|
||||
);
|
||||
}
|
||||
|
||||
private downloadSeries(series: Series) {
|
||||
|
||||
// TODO: Call backend for all the volumes and loose leaf chapters then enqueque them all
|
||||
|
||||
const downloadType = 'series';
|
||||
const subtitle = this.downloadSubtitle(downloadType, series);
|
||||
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
|
||||
@ -227,38 +289,42 @@ export class DownloadService {
|
||||
}
|
||||
|
||||
private downloadChapter(chapter: Chapter) {
|
||||
const downloadType = 'chapter';
|
||||
const subtitle = this.downloadSubtitle(downloadType, chapter);
|
||||
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, decodeURIComponent(filename));
|
||||
}),
|
||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)),
|
||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
return this.downloadEntity(chapter);
|
||||
|
||||
// const downloadType = 'chapter';
|
||||
// const subtitle = this.downloadSubtitle(downloadType, chapter);
|
||||
// return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
||||
// {observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
// ).pipe(
|
||||
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
// download((blob, filename) => {
|
||||
// this.save(blob, decodeURIComponent(filename));
|
||||
// }),
|
||||
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)),
|
||||
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
// );
|
||||
}
|
||||
|
||||
private downloadVolume(volume: Volume): Observable<Download> {
|
||||
const downloadType = 'volume';
|
||||
const subtitle = this.downloadSubtitle(downloadType, volume);
|
||||
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, decodeURIComponent(filename));
|
||||
}),
|
||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)),
|
||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
private downloadVolume(volume: Volume) {
|
||||
return this.downloadEntity(volume);
|
||||
// const downloadType = 'volume';
|
||||
// const subtitle = this.downloadSubtitle(downloadType, volume);
|
||||
// return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
|
||||
// {observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
// ).pipe(
|
||||
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
// download((blob, filename) => {
|
||||
// this.save(blob, decodeURIComponent(filename));
|
||||
// }),
|
||||
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)),
|
||||
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
// );
|
||||
}
|
||||
|
||||
private async confirmSize(size: number, entityType: DownloadEntityType) {
|
||||
return (size < this.SIZE_WARNING ||
|
||||
await this.confirmService.confirm(translate('toasts.confirm-download-size', {entityType: translate('entity-type.' + entityType), size: bytesPipe.transform(size)})));
|
||||
await this.confirmService.confirm(translate('toasts.confirm-download-size',
|
||||
{entityType: translate('entity-type.' + entityType), size: bytesPipe.transform(size)})));
|
||||
}
|
||||
|
||||
private downloadBookmarks(bookmarks: PageBookmark[]) {
|
||||
@ -276,4 +342,60 @@ export class DownloadService {
|
||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private processDownload(entity: QueueableDownloadType): void {
|
||||
const downloadObservable = this.downloadEntity(entity);
|
||||
console.log('Process Download called for entity: ', entity);
|
||||
|
||||
// When we consume one, we need to take it off the queue
|
||||
|
||||
downloadObservable.subscribe((downloadEvent) => {
|
||||
// Download completed, process the next item in the queue
|
||||
if (downloadEvent.state === 'DONE') {
|
||||
this.processNextDownload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private processNextDownload(): void {
|
||||
const currentQueue = this.downloadQueue.value;
|
||||
if (currentQueue.length > 0) {
|
||||
const nextEntity = currentQueue[0];
|
||||
this.processDownload(nextEntity);
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueDownload(entity: QueueableDownloadType): void {
|
||||
const currentQueue = this.downloadQueue.value;
|
||||
const newQueue = [...currentQueue, entity];
|
||||
this.downloadQueue.next(newQueue);
|
||||
|
||||
// If the queue was empty, start processing the download
|
||||
if (currentQueue.length === 0) {
|
||||
this.processNextDownload();
|
||||
}
|
||||
}
|
||||
|
||||
mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter) {
|
||||
if(this.utilityService.isSeries(entity)) {
|
||||
return events.find(e => e.entityType === 'series' && e.id == entity.id
|
||||
&& e.subTitle === this.downloadSubtitle('series', (entity as Series))) || null;
|
||||
}
|
||||
if(this.utilityService.isVolume(entity)) {
|
||||
return events.find(e => e.entityType === 'volume' && e.id == entity.id
|
||||
&& e.subTitle === this.downloadSubtitle('volume', (entity as Volume))) || null;
|
||||
}
|
||||
if(this.utilityService.isChapter(entity)) {
|
||||
return events.find(e => e.entityType === 'chapter' && e.id == entity.id
|
||||
&& e.subTitle === this.downloadSubtitle('chapter', (entity as Chapter))) || null;
|
||||
}
|
||||
// Is PageBookmark[]
|
||||
if(entity.hasOwnProperty('length')) {
|
||||
return events.find(e => e.entityType === 'bookmark'
|
||||
&& e.subTitle === this.downloadSubtitle('bookmark', [(entity as PageBookmark)])) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -157,6 +157,9 @@ export class SideNavComponent implements OnInit {
|
||||
case (Action.AnalyzeFiles):
|
||||
await this.actionService.analyzeFiles(library);
|
||||
break;
|
||||
case (Action.Delete):
|
||||
await this.actionService.deleteLibrary(library);
|
||||
break;
|
||||
case (Action.Edit):
|
||||
this.actionService.editLibrary(library, () => window.scrollTo(0, 0));
|
||||
break;
|
||||
|
@ -3,7 +3,18 @@
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<div class="container-fluid row mb-2">
|
||||
<div class="col-10 col-sm-11"><h4 id="anilist-token-header">{{t('title')}}</h4></div>
|
||||
<div class="col-10 col-sm-11">
|
||||
<h4 id="anilist-token-header">{{t('title')}}
|
||||
@if(!tokenExpired) {
|
||||
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
|
||||
<span class="visually-hidden">{{t('token-valid')}}</span>
|
||||
} @else {
|
||||
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-not-valid')"></i>
|
||||
<span class="visually-hidden">{{t('token-not-valid')}}</span>
|
||||
}
|
||||
</h4>
|
||||
|
||||
</div>
|
||||
<div class="col-1 text-end">
|
||||
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? t('edit') : t('cancel')}}</button>
|
||||
</div>
|
||||
|
@ -1,3 +1,9 @@
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
@ -5,10 +5,13 @@
|
||||
<div class="container-fluid row mb-2">
|
||||
<div class="col-10 col-sm-11">
|
||||
<h4 id="email-card">{{t('email-label')}}
|
||||
<ng-container *ngIf="!emailConfirmed">
|
||||
<i class="fa-solid fa-circle ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('email-not-confirmed')"></i>
|
||||
@if(emailConfirmed) {
|
||||
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('email-confirmed')"></i>
|
||||
<span class="visually-hidden">{{t('email-confirmed')}}</span>
|
||||
} @else {
|
||||
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('email-not-confirmed')"></i>
|
||||
<span class="visually-hidden">{{t('email-not-confirmed')}}</span>
|
||||
</ng-container>
|
||||
}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col-1 text-end">
|
||||
|
@ -3,3 +3,7 @@
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
@ -245,6 +245,7 @@
|
||||
"email-label": "{{common.email}}",
|
||||
"current-password-label": "Current Password",
|
||||
"email-not-confirmed": "This email is not confirmed",
|
||||
"email-confirmed": "This email is confirmed",
|
||||
"email-updated-title": "Email Updated",
|
||||
"email-updated-description": "You can use the following link below to confirm the email for your account. If your server is externally accessible, an email will have been sent to the email and the link can be used to confirm the email.",
|
||||
"setup-user-account": "Setup user's account",
|
||||
@ -722,6 +723,7 @@
|
||||
|
||||
"series-metadata-detail": {
|
||||
"links-title": "Links",
|
||||
"rating-title": "Ratings",
|
||||
"genres-title": "Genres",
|
||||
"tags-title": "Tags",
|
||||
"collections-title": "{{side-nav.collections}}",
|
||||
@ -2000,7 +2002,8 @@
|
||||
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
|
||||
"smart-filter-deleted": "Smart Filter Deleted",
|
||||
"smart-filter-updated": "Created/Updated smart filter",
|
||||
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key"
|
||||
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key",
|
||||
"anilist-token-expired": "Your AniList token is expired. Scrobbling will no longer process until you re-generate it in User Settings > Account"
|
||||
},
|
||||
|
||||
"actionable": {
|
||||
|
@ -13,6 +13,9 @@
|
||||
--primary-color-scrollbar: rgba(74,198,148,0.75);
|
||||
--text-muted-color: lightgrey;
|
||||
|
||||
/* New Color scheme */
|
||||
--secondary-color: #212328;
|
||||
|
||||
/* Meta and Globals */
|
||||
--theme-color: #000000;
|
||||
--color-scheme: dark;
|
||||
|
68
openapi.json
68
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.12.2"
|
||||
"version": "0.7.12.3"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -3538,6 +3538,7 @@
|
||||
"tags": [
|
||||
"Metadata"
|
||||
],
|
||||
"summary": "Returns all languages Kavita can accept",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@ -3612,6 +3613,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Metadata/series-detail-plus": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Metadata"
|
||||
],
|
||||
"summary": "Fetches the details needed from Kavita+ for Series Detail page",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "seriesId",
|
||||
"in": "query",
|
||||
"description": "",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SeriesDetailPlusDto"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SeriesDetailPlusDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SeriesDetailPlusDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Opds/{apiKey}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@ -17652,6 +17694,30 @@
|
||||
"additionalProperties": false,
|
||||
"description": "This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout.\r\nThis is subject to change, do not rely on this Data model."
|
||||
},
|
||||
"SeriesDetailPlusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recommendations": {
|
||||
"$ref": "#/components/schemas/RecommendationDto"
|
||||
},
|
||||
"reviews": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UserReviewDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"ratings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RatingDto"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "All the data from Kavita+ for Series Detail"
|
||||
},
|
||||
"SeriesDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user