mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-31 20:24:27 -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));
|
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 System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs.Account;
|
|
||||||
using API.DTOs.License;
|
using API.DTOs.License;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
@ -20,7 +19,8 @@ public class LicenseController(
|
|||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
ILogger<LicenseController> logger,
|
ILogger<LicenseController> logger,
|
||||||
ILicenseService licenseService,
|
ILicenseService licenseService,
|
||||||
ILocalizationService localizationService)
|
ILocalizationService localizationService,
|
||||||
|
ITaskScheduler taskScheduler)
|
||||||
: BaseApiController
|
: BaseApiController
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -31,7 +31,12 @@ public class LicenseController(
|
|||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
|
||||||
public async Task<ActionResult<bool>> HasValidLicense(bool forceCheck = false)
|
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>
|
/// <summary>
|
||||||
@ -57,6 +62,7 @@ public class LicenseController(
|
|||||||
setting.Value = null;
|
setting.Value = null;
|
||||||
unitOfWork.SettingsRepository.Update(setting);
|
unitOfWork.SettingsRepository.Update(setting);
|
||||||
await unitOfWork.CommitAsync();
|
await unitOfWork.CommitAsync();
|
||||||
|
await taskScheduler.ScheduleKavitaPlusTasks();
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +88,7 @@ public class LicenseController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await licenseService.AddLicense(dto.License.Trim(), dto.Email.Trim(), dto.DiscordId);
|
await licenseService.AddLicense(dto.License.Trim(), dto.Email.Trim(), dto.DiscordId);
|
||||||
|
await taskScheduler.ScheduleKavitaPlusTasks();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
@ -8,9 +8,11 @@ using API.Data;
|
|||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
|
using API.DTOs.SeriesDetail;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Services.Plus;
|
||||||
using Kavita.Common.Extensions;
|
using Kavita.Common.Extensions;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -18,17 +20,10 @@ namespace API.Controllers;
|
|||||||
|
|
||||||
#nullable enable
|
#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>
|
/// <summary>
|
||||||
/// Fetches genres from the instance
|
/// Fetches genres from the instance
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -41,10 +36,10 @@ public class MetadataController : BaseApiController
|
|||||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||||
if (ids != null && ids.Count > 0)
|
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>
|
/// <summary>
|
||||||
@ -57,8 +52,8 @@ public class MetadataController : BaseApiController
|
|||||||
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
|
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
|
||||||
{
|
{
|
||||||
return role.HasValue ?
|
return role.HasValue ?
|
||||||
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
|
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
|
||||||
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
|
Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -73,9 +68,9 @@ public class MetadataController : BaseApiController
|
|||||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||||
if (ids != null && ids.Count > 0)
|
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>
|
/// <summary>
|
||||||
@ -90,9 +85,9 @@ public class MetadataController : BaseApiController
|
|||||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||||
if (ids != null && ids.Count > 0)
|
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>
|
/// <summary>
|
||||||
@ -108,7 +103,7 @@ public class MetadataController : BaseApiController
|
|||||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||||
if (ids != null && ids.Count > 0)
|
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()
|
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();
|
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
||||||
if (ids is {Count: > 0})
|
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()
|
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)
|
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
|
||||||
{
|
{
|
||||||
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
|
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")]
|
[HttpGet("all-languages")]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
|
||||||
public IEnumerable<LanguageDto> GetAllValidLanguages()
|
public IEnumerable<LanguageDto> GetAllValidLanguages()
|
||||||
@ -177,9 +175,38 @@ public class MetadataController : BaseApiController
|
|||||||
[HttpGet("chapter-summary")]
|
[HttpGet("chapter-summary")]
|
||||||
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
|
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
|
||||||
{
|
{
|
||||||
if (chapterId <= 0) 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);
|
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||||
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||||
return Ok(chapter.Summary);
|
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>
|
/// <param name="seriesId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
|
||||||
public async Task<ActionResult<IEnumerable<RatingDto>>> GetRating(int seriesId)
|
public async Task<ActionResult<IEnumerable<RatingDto>>> GetRating(int seriesId)
|
||||||
{
|
{
|
||||||
|
|
||||||
if (!await _licenseService.HasActiveLicense())
|
if (!await _licenseService.HasActiveLicense())
|
||||||
{
|
{
|
||||||
return Ok(Enumerable.Empty<RatingDto>());
|
return Ok(Enumerable.Empty<RatingDto>());
|
||||||
}
|
}
|
||||||
|
return Ok(await _ratingService.GetRatings(seriesId));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("overall")]
|
[HttpGet("overall")]
|
||||||
|
@ -51,78 +51,10 @@ public class ReviewController : BaseApiController
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="seriesId"></param>
|
/// <param name="seriesId"></param>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])]
|
||||||
public async Task<ActionResult<IEnumerable<UserReviewDto>>> GetReviews(int seriesId)
|
public async Task<ActionResult<IEnumerable<UserReviewDto>>> GetReviews(int seriesId)
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
return Ok(await _reviewService.GetReviewsForSeries(User.GetUserId(), seriesId));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
|
using API.DTOs;
|
||||||
using API.DTOs.Recommendation;
|
using API.DTOs.Recommendation;
|
||||||
using API.DTOs.Scrobbling;
|
using API.DTOs.Scrobbling;
|
||||||
|
using API.DTOs.SeriesDetail;
|
||||||
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Extensions;
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
@ -29,9 +34,17 @@ internal class ExternalMetadataIdsDto
|
|||||||
public MediaFormat? PlusMediaFormat { get; set; } = MediaFormat.Unknown;
|
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
|
public interface IExternalMetadataService
|
||||||
{
|
{
|
||||||
Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId);
|
Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId);
|
||||||
|
Task<SeriesDetailPlusDto?> GetSeriesDetail(int userId, int seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExternalMetadataService : IExternalMetadataService
|
public class ExternalMetadataService : IExternalMetadataService
|
||||||
@ -48,6 +61,14 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
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)
|
public async Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId)
|
||||||
{
|
{
|
||||||
if (!aniListId.HasValue && !malId.HasValue)
|
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)
|
private async Task<ExternalSeriesDetailDto?> GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId)
|
||||||
{
|
{
|
||||||
var payload = new ExternalMetadataIdsDto()
|
var payload = new ExternalMetadataIdsDto()
|
||||||
|
@ -7,7 +7,6 @@ using API.DTOs.License;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using EasyCaching.Core;
|
using EasyCaching.Core;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using Hangfire;
|
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Kavita.Common.EnvironmentInfo;
|
using Kavita.Common.EnvironmentInfo;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -24,31 +23,25 @@ internal class RegisterLicenseResponseDto
|
|||||||
|
|
||||||
public interface ILicenseService
|
public interface ILicenseService
|
||||||
{
|
{
|
||||||
Task ValidateLicenseStatus();
|
//Task ValidateLicenseStatus();
|
||||||
Task RemoveLicense();
|
Task RemoveLicense();
|
||||||
Task AddLicense(string license, string email, string? discordId);
|
Task AddLicense(string license, string email, string? discordId);
|
||||||
Task<bool> HasActiveLicense(bool forceCheck = false);
|
Task<bool> HasActiveLicense(bool forceCheck = false);
|
||||||
|
Task<bool> HasActiveSubscription(string? license);
|
||||||
Task<bool> ResetLicense(string license, string email);
|
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);
|
private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8);
|
||||||
public const string Cron = "0 */4 * * *";
|
public const string Cron = "0 */4 * * *";
|
||||||
private const string CacheKey = "license";
|
private const string CacheKey = "license";
|
||||||
|
|
||||||
|
|
||||||
public LicenseService(IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork, ILogger<LicenseService> logger)
|
|
||||||
{
|
|
||||||
_cachingProviderFactory = cachingProviderFactory;
|
|
||||||
_unitOfWork = unitOfWork;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs license lookup to API layer
|
/// Performs license lookup to API layer
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -77,7 +70,7 @@ public class LicenseService : ILicenseService
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
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;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,12 +108,12 @@ public class LicenseService : ILicenseService
|
|||||||
return response.EncryptedLicense;
|
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);
|
throw new KavitaException(response.ErrorMessage);
|
||||||
}
|
}
|
||||||
catch (FlurlHttpException e)
|
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;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -129,57 +122,41 @@ public class LicenseService : ILicenseService
|
|||||||
/// Checks licenses and updates cache
|
/// Checks licenses and updates cache
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>Expected to be called at startup and on reoccurring basis</remarks>
|
/// <remarks>Expected to be called at startup and on reoccurring basis</remarks>
|
||||||
public async Task ValidateLicenseStatus()
|
// public async Task ValidateLicenseStatus()
|
||||||
{
|
// {
|
||||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
// var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||||
try
|
// try
|
||||||
{
|
// {
|
||||||
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
// var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||||
if (string.IsNullOrEmpty(license.Value)) {
|
// if (string.IsNullOrEmpty(license.Value)) {
|
||||||
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
// await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
_logger.LogInformation("Validating Kavita+ License");
|
// _logger.LogInformation("Validating Kavita+ License");
|
||||||
|
//
|
||||||
await provider.FlushAsync();
|
// await provider.FlushAsync();
|
||||||
var isValid = await IsLicenseValid(license.Value);
|
// var isValid = await IsLicenseValid(license.Value);
|
||||||
await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
|
// await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
|
||||||
|
//
|
||||||
_logger.LogInformation("Validating Kavita+ License - Complete");
|
// _logger.LogInformation("Validating Kavita+ License - Complete");
|
||||||
}
|
// }
|
||||||
catch (Exception ex)
|
// catch (Exception ex)
|
||||||
{
|
// {
|
||||||
_logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins");
|
// _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);
|
// await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
||||||
BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30));
|
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// <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)
|
public async Task<bool> HasActiveLicense(bool forceCheck = false)
|
||||||
{
|
{
|
||||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||||
if (!forceCheck)
|
if (!forceCheck)
|
||||||
{
|
{
|
||||||
var cacheValue = await provider.GetAsync<bool>(CacheKey);
|
var cacheValue = await provider.GetAsync<bool>(CacheKey);
|
||||||
@ -188,7 +165,7 @@ public class LicenseService : ILicenseService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||||
var result = await IsLicenseValid(serverSetting.Value);
|
var result = await IsLicenseValid(serverSetting.Value);
|
||||||
await provider.FlushAsync();
|
await provider.FlushAsync();
|
||||||
await provider.SetAsync(CacheKey, result, _licenseCacheTimeout);
|
await provider.SetAsync(CacheKey, result, _licenseCacheTimeout);
|
||||||
@ -196,17 +173,77 @@ public class LicenseService : ILicenseService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
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)
|
public async Task<bool> ResetLicense(string license, string email)
|
||||||
{
|
{
|
||||||
try
|
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")
|
var response = await (Configuration.KavitaPlusApiUrl + "/api/license/reset")
|
||||||
.WithHeader("Accept", "application/json")
|
.WithHeader("Accept", "application/json")
|
||||||
.WithHeader("User-Agent", "Kavita")
|
.WithHeader("User-Agent", "Kavita")
|
||||||
@ -225,17 +262,17 @@ public class LicenseService : ILicenseService
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(response))
|
if (string.IsNullOrEmpty(response))
|
||||||
{
|
{
|
||||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||||
await provider.RemoveAsync(CacheKey);
|
await provider.RemoveAsync(CacheKey);
|
||||||
return true;
|
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);
|
throw new KavitaException(response);
|
||||||
}
|
}
|
||||||
catch (FlurlHttpException e)
|
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;
|
return false;
|
||||||
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
@ -10,6 +11,7 @@ using API.Entities;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
|
using EasyCaching.Core;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Kavita.Common.EnvironmentInfo;
|
using Kavita.Common.EnvironmentInfo;
|
||||||
@ -28,25 +30,51 @@ public class RatingService : IRatingService
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ILogger<RatingService> _logger;
|
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;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
||||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
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)
|
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 license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
||||||
SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Chapters | SeriesIncludes.Volumes);
|
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
|
// 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;
|
if (series == null || series.Library.Type == LibraryType.Comic)
|
||||||
return await GetRatings(license.Value, series);
|
{
|
||||||
|
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)
|
private async Task<IEnumerable<RatingDto>> GetRatings(string license, Series series)
|
||||||
|
@ -40,7 +40,7 @@ public record PlusSeriesDto
|
|||||||
public int? Year { get; set; }
|
public int? Year { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal record MediaRecommendationDto
|
public record MediaRecommendationDto
|
||||||
{
|
{
|
||||||
public int Rating { get; set; }
|
public int Rating { get; set; }
|
||||||
public IEnumerable<string> RecommendationNames { get; set; } = null!;
|
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
|
try
|
||||||
{
|
{
|
||||||
|
@ -7,7 +7,6 @@ using System.Threading.Tasks;
|
|||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.DTOs.Recommendation;
|
|
||||||
using API.DTOs.Scrobbling;
|
using API.DTOs.Scrobbling;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
@ -78,7 +77,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
{MangaDexWeblinkWebsite, 0},
|
{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>()
|
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"))
|
if (response.ErrorMessage != null && response.ErrorMessage.Contains("Too Many Requests"))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Hit Too many requests, sleeping to regain requests");
|
_logger.LogInformation("Hit Too many requests, sleeping to regain requests");
|
||||||
await Task.Delay(TimeSpan.FromMinutes(1));
|
await Task.Delay(TimeSpan.FromMinutes(5));
|
||||||
} else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series"))
|
} 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
|
// Log the Series name and Id in ScrobbleErrors
|
||||||
_logger.LogInformation("Kavita+ was unable to match the series");
|
_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 => librariesWithScrobbling.Contains(e.LibraryId))
|
||||||
.Where(e => !errors.Contains(e.SeriesId))
|
.Where(e => !errors.Contains(e.SeriesId))
|
||||||
.ToList();
|
.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
|
var decisions = addToWantToRead
|
||||||
.GroupBy(item => new { item.SeriesId, item.AppUserId })
|
.GroupBy(item => new { item.SeriesId, item.AppUserId })
|
||||||
.Select(group => new
|
.Select(group => new
|
||||||
@ -645,7 +650,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
await SetAndCheckRateLimit(userRateLimits, user, license.Value);
|
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);
|
_logger.LogInformation("Found {TotalEvents} Scrobble Events", totalProgress);
|
||||||
try
|
try
|
||||||
@ -692,22 +697,6 @@ public class ScrobblingService : IScrobblingService
|
|||||||
Year = evt.Series.Metadata.ReleaseYear
|
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,
|
progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter,
|
||||||
totalProgress, evt => Task.FromResult(new ScrobbleDto()
|
totalProgress, evt => Task.FromResult(new ScrobbleDto()
|
||||||
{
|
{
|
||||||
@ -766,7 +755,22 @@ public class ScrobblingService : IScrobblingService
|
|||||||
{
|
{
|
||||||
continue;
|
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);
|
var count = await SetAndCheckRateLimit(userRateLimits, evt.AppUser, license.Value);
|
||||||
|
userRateLimits[evt.AppUserId] = count;
|
||||||
if (count == 0)
|
if (count == 0)
|
||||||
{
|
{
|
||||||
if (usersToScrobble == 1) break;
|
if (usersToScrobble == 1) break;
|
||||||
@ -786,6 +790,14 @@ public class ScrobblingService : IScrobblingService
|
|||||||
// If a flurl exception occured, the API is likely down. Kill processing
|
// If a flurl exception occured, the API is likely down. Kill processing
|
||||||
throw;
|
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)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
/* Swallow as it's already been handled in PostScrobbleUpdate */
|
/* Swallow as it's already been handled in PostScrobbleUpdate */
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
@ -11,6 +14,7 @@ using API.Entities.Enums;
|
|||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
|
using EasyCaching.Core;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using HtmlAgilityPack;
|
using HtmlAgilityPack;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
@ -48,18 +52,98 @@ public class ReviewService : IReviewService
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ILogger<ReviewService> _logger;
|
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;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_licenseService = licenseService;
|
||||||
|
|
||||||
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
||||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||||
|
|
||||||
|
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<UserReviewDto>> GetReviewsForSeries(int userId, int seriesId)
|
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 =
|
var series =
|
||||||
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId,
|
||||||
|
@ -19,6 +19,7 @@ public interface ITaskScheduler
|
|||||||
Task ScheduleTasks();
|
Task ScheduleTasks();
|
||||||
Task ScheduleStatsTasks();
|
Task ScheduleStatsTasks();
|
||||||
void ScheduleUpdaterTasks();
|
void ScheduleUpdaterTasks();
|
||||||
|
Task ScheduleKavitaPlusTasks();
|
||||||
void ScanFolder(string folderPath, TimeSpan delay);
|
void ScanFolder(string folderPath, TimeSpan delay);
|
||||||
void ScanFolder(string folderPath);
|
void ScanFolder(string folderPath);
|
||||||
void ScanLibrary(int libraryId, bool force = false);
|
void ScanLibrary(int libraryId, bool force = false);
|
||||||
@ -72,7 +73,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
public const string LicenseCheck = "license-check";
|
public const string LicenseCheck = "license-check";
|
||||||
|
|
||||||
private static readonly ImmutableArray<string> ScanTasks =
|
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();
|
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(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions);
|
||||||
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions);
|
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions);
|
||||||
|
|
||||||
|
await ScheduleKavitaPlusTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ScheduleKavitaPlusTasks()
|
||||||
|
{
|
||||||
// KavitaPlus based (needs license check)
|
// 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);
|
RecurringJob.AddOrUpdate(CheckScrobblingTokens, () => _scrobblingService.CheckExternalAccessTokens(), Cron.Daily, RecurringJobOptions);
|
||||||
BackgroundJob.Enqueue(() => _scrobblingService.CheckExternalAccessTokens()); // We also kick off an immediate check on startup
|
BackgroundJob.Enqueue(() => _scrobblingService.CheckExternalAccessTokens()); // We also kick off an immediate check on startup
|
||||||
RecurringJob.AddOrUpdate(LicenseCheck, () => _licenseService.ValidateLicenseStatus(), LicenseService.Cron, RecurringJobOptions);
|
RecurringJob.AddOrUpdate(LicenseCheck, () => _licenseService.HasActiveLicense(true), LicenseService.Cron, RecurringJobOptions);
|
||||||
BackgroundJob.Enqueue(() => _licenseService.ValidateLicenseStatus());
|
BackgroundJob.Enqueue(() => _licenseService.HasActiveLicense(true));
|
||||||
|
|
||||||
// KavitaPlus Scrobbling (every 4 hours)
|
// KavitaPlus Scrobbling (every 4 hours)
|
||||||
RecurringJob.AddOrUpdate(ProcessScrobblingEvents, () => _scrobblingService.ProcessUpdatesSinceLastSync(), "0 */4 * * *", RecurringJobOptions);
|
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,
|
requiresAdmin: true,
|
||||||
children: [],
|
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
|
* Mark a series as read; updates the series pagesRead
|
||||||
* @param series Series, must have id and name populated
|
* @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 {FilterCombination} from "../_models/metadata/v2/filter-combination";
|
||||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||||
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
|
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
|
||||||
|
import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -25,7 +26,11 @@ export class MetadataService {
|
|||||||
baseUrl = environment.apiUrl;
|
baseUrl = environment.apiUrl;
|
||||||
private validLanguages: Array<Language> = [];
|
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>) {
|
getAllAgeRatings(libraries?: Array<number>) {
|
||||||
let method = 'metadata/age-ratings'
|
let method = 'metadata/age-ratings'
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
|
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
|
||||||
import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe";
|
import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe";
|
||||||
@ -11,10 +11,11 @@ import {debounceTime, take} from "rxjs/operators";
|
|||||||
import {PaginatedResult, Pagination} from "../../_models/pagination";
|
import {PaginatedResult, Pagination} from "../../_models/pagination";
|
||||||
import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive";
|
import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive";
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
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 {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||||
|
import {ToastrService} from "ngx-toastr";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-scrobble-history',
|
selector: 'app-user-scrobble-history',
|
||||||
@ -26,9 +27,11 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
|||||||
})
|
})
|
||||||
export class UserScrobbleHistoryComponent implements OnInit {
|
export class UserScrobbleHistoryComponent implements OnInit {
|
||||||
|
|
||||||
private readonly scrobbleService = inject(ScrobblingService);
|
private readonly scrobblingService = inject(ScrobblingService);
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
protected readonly ScrobbleEventType = ScrobbleEventType;
|
||||||
|
|
||||||
pagination: Pagination | undefined;
|
pagination: Pagination | undefined;
|
||||||
events: Array<ScrobbleEvent> = [];
|
events: Array<ScrobbleEvent> = [];
|
||||||
@ -36,11 +39,16 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
|||||||
'filter': new FormControl('', [])
|
'filter': new FormControl('', [])
|
||||||
});
|
});
|
||||||
|
|
||||||
get ScrobbleEventType() { return ScrobbleEventType; }
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.loadPage({column: 'createdUtc', direction: 'desc'});
|
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.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => {
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
})
|
})
|
||||||
@ -73,7 +81,7 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
|||||||
const field = this.mapSortColumnField(sortEvent?.column);
|
const field = this.mapSortColumnField(sortEvent?.column);
|
||||||
const query = this.formGroup.get('filter')?.value;
|
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))
|
.pipe(take(1))
|
||||||
.subscribe((result: PaginatedResult<ScrobbleEvent[]>) => {
|
.subscribe((result: PaginatedResult<ScrobbleEvent[]>) => {
|
||||||
this.events = result.result;
|
this.events = result.result;
|
||||||
|
@ -14,8 +14,6 @@ export interface DirectoryPickerResult {
|
|||||||
folderPath: string;
|
folderPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-directory-picker',
|
selector: 'app-directory-picker',
|
||||||
templateUrl: './directory-picker.component.html',
|
templateUrl: './directory-picker.component.html',
|
||||||
|
@ -24,6 +24,7 @@ import { RouterLink } from '@angular/router';
|
|||||||
import { NgFor, NgIf } from '@angular/common';
|
import { NgFor, NgIf } from '@angular/common';
|
||||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||||
|
import {ActionService} from "../../_services/action.service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manage-library',
|
selector: 'app-manage-library',
|
||||||
@ -35,6 +36,15 @@ import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
|||||||
})
|
})
|
||||||
export class ManageLibraryComponent implements OnInit {
|
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[] = [];
|
libraries: Library[] = [];
|
||||||
loading = false;
|
loading = false;
|
||||||
/**
|
/**
|
||||||
@ -42,11 +52,8 @@ export class ManageLibraryComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
deletionInProgress: boolean = false;
|
deletionInProgress: boolean = false;
|
||||||
libraryTrackBy = (index: number, item: Library) => `${item.name}_${item.lastScanned}_${item.type}_${item.folders.length}`;
|
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 {
|
ngOnInit(): void {
|
||||||
this.getLibraries();
|
this.getLibraries();
|
||||||
|
@ -19,7 +19,8 @@
|
|||||||
<app-card-item [entity]="item" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
|
<app-card-item [entity]="item" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
|
||||||
[suppressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
|
[suppressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
|
||||||
[actions]="actions"
|
[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>
|
></app-card-item>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
@ -9,11 +9,11 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { take } from 'rxjs';
|
import {take} from 'rxjs';
|
||||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||||
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
||||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
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 { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
|
||||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||||
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
|
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 { ImageService } from 'src/app/_services/image.service';
|
||||||
import { JumpbarService } from 'src/app/_services/jumpbar.service';
|
import { JumpbarService } from 'src/app/_services/jumpbar.service';
|
||||||
import { ReaderService } from 'src/app/_services/reader.service';
|
import { ReaderService } from 'src/app/_services/reader.service';
|
||||||
import { SeriesService } from 'src/app/_services/series.service';
|
|
||||||
import {DecimalPipe, NgIf} from '@angular/common';
|
import {DecimalPipe, NgIf} from '@angular/common';
|
||||||
import { CardItemComponent } from '../../../cards/card-item/card-item.component';
|
import { CardItemComponent } from '../../../cards/card-item/card-item.component';
|
||||||
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.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 {
|
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> = [];
|
bookmarks: Array<PageBookmark> = [];
|
||||||
series: Array<Series> = [];
|
series: Array<Series> = [];
|
||||||
loadingBookmarks: boolean = false;
|
loadingBookmarks: boolean = false;
|
||||||
seriesIds: {[id: number]: number} = {};
|
seriesIds: {[id: number]: number} = {};
|
||||||
downloadingSeries: {[id: number]: boolean} = {};
|
|
||||||
clearingSeries: {[id: number]: boolean} = {};
|
clearingSeries: {[id: number]: boolean} = {};
|
||||||
actions: ActionItem<Series>[] = [];
|
actions: ActionItem<Series>[] = [];
|
||||||
jumpbarKeys: Array<JumpKey> = [];
|
jumpbarKeys: Array<JumpKey> = [];
|
||||||
@ -64,16 +77,7 @@ export class BookmarksComponent implements OnInit {
|
|||||||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||||
refresh: EventEmitter<void> = new EventEmitter();
|
refresh: EventEmitter<void> = new EventEmitter();
|
||||||
|
|
||||||
private readonly translocoService = inject(TranslocoService);
|
constructor() {
|
||||||
|
|
||||||
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) {
|
|
||||||
|
|
||||||
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
|
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
|
||||||
this.filter = filter;
|
this.filter = filter;
|
||||||
|
|
||||||
@ -160,8 +164,11 @@ export class BookmarksComponent implements OnInit {
|
|||||||
this.readerService.getAllBookmarks(this.filter).pipe(take(1)).subscribe(bookmarks => {
|
this.readerService.getAllBookmarks(this.filter).pipe(take(1)).subscribe(bookmarks => {
|
||||||
this.bookmarks = bookmarks;
|
this.bookmarks = bookmarks;
|
||||||
this.bookmarks.forEach(bmk => {
|
this.bookmarks.forEach(bmk => {
|
||||||
this.downloadingSeries[bmk.seriesId] = false;
|
|
||||||
this.clearingSeries[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();
|
const distinctSeriesMap = new Map();
|
||||||
@ -199,14 +206,7 @@ export class BookmarksComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadBookmarks(series: Series) {
|
downloadBookmarks(series: Series) {
|
||||||
this.downloadingSeries[series.id] = true;
|
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id));
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFilter(data: FilterEvent) {
|
updateFilter(data: FilterEvent) {
|
||||||
|
@ -108,45 +108,52 @@
|
|||||||
<li [ngbNavItem]="tabs[TabID.Files]" [disabled]="(isAdmin$ | async) === false">
|
<li [ngbNavItem]="tabs[TabID.Files]" [disabled]="(isAdmin$ | async) === false">
|
||||||
<a ngbNavLink>{{t(tabs[TabID.Files].title)}}</a>
|
<a ngbNavLink>{{t(tabs[TabID.Files].title)}}</a>
|
||||||
<ng-template ngbNavContent>
|
<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">
|
<ul class="list-unstyled">
|
||||||
<li class="d-flex my-4" *ngFor="let chapter of chapters">
|
<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)}}">
|
<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>
|
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<h5 class="mt-0 mb-1">
|
<h5 class="mt-0 mb-1">
|
||||||
<span>
|
<span>
|
||||||
<span>
|
<span>
|
||||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
|
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
|
||||||
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
||||||
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
|
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
|
||||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</span>
|
</span>
|
||||||
<span class="badge bg-primary rounded-pill ms-1">
|
<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 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
|
||||||
<span *ngIf="chapter.pagesRead === 0">{{t('unread') | uppercase}}</span>
|
<span *ngIf="chapter.pagesRead === 0">{{t('unread') | uppercase}}</span>
|
||||||
<span *ngIf="chapter.pagesRead === chapter.pages">{{t('read') | uppercase}}</span>
|
<span *ngIf="chapter.pagesRead === chapter.pages">{{t('read') | uppercase}}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ng-template #specialHeader>{{t('files')}}</ng-template>
|
<ng-template #specialHeader>{{t('files')}}</ng-template>
|
||||||
</h5>
|
</h5>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
|
@for (file of chapter.files; track file.id) {
|
||||||
<span>{{file.filePath}}</span>
|
<li class="list-group-item no-hover">
|
||||||
<div class="row g-0">
|
<span>{{file.filePath}}</span>
|
||||||
<div class="col">
|
<div class="row g-0">
|
||||||
{{t('pages')}} {{file.pages | number:''}}
|
<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>
|
||||||
<div class="col" *ngIf="data.hasOwnProperty('created')">
|
</li>
|
||||||
{{t('added')}} {{file.created | date: 'short' | defaultDate}}
|
}
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
{{t('size')}} {{file.bytes | bytes}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
@ -68,12 +68,17 @@ enum TabID {
|
|||||||
})
|
})
|
||||||
export class CardDetailDrawerComponent implements OnInit {
|
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() parentName = '';
|
||||||
@Input() seriesId: number = 0;
|
@Input() seriesId: number = 0;
|
||||||
@Input() libraryId: number = 0;
|
@Input() libraryId: number = 0;
|
||||||
@Input({required: true}) data!: Volume | Chapter;
|
@Input({required: true}) data!: Volume | Chapter;
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If this is a volume, this will be first chapter for said volume.
|
* 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];
|
active = this.tabs[0];
|
||||||
|
|
||||||
chapterMetadata!: ChapterMetadata;
|
chapterMetadata: ChapterMetadata | undefined;
|
||||||
summary: string = '';
|
summary: string = '';
|
||||||
|
|
||||||
downloadInProgress: boolean = false;
|
downloadInProgress: boolean = false;
|
||||||
|
|
||||||
get MangaFormat() {
|
|
||||||
return MangaFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
get Breakpoint() {
|
|
||||||
return Breakpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
get LibraryType() {
|
|
||||||
return LibraryType;
|
|
||||||
}
|
|
||||||
|
|
||||||
get TabID() {
|
|
||||||
return TabID;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(public utilityService: UtilityService,
|
constructor(public utilityService: UtilityService,
|
||||||
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
|
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))
|
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
|
||||||
.filter(item => item.action !== Action.Edit);
|
.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) {
|
if (this.isChapter) {
|
||||||
const chapter = this.utilityService.asChapter(this.data);
|
const chapter = this.utilityService.asChapter(this.data);
|
||||||
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter);
|
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) => {
|
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
||||||
if(this.utilityService.isSeries(this.entity)) {
|
console.log('Card Item download obv called for entity: ', this.entity);
|
||||||
return events.find(e => e.entityType === 'series' && e.id == this.entity.id
|
return this.downloadService.mapToEntityType(events, this.entity);
|
||||||
&& 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;
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,86 +1,98 @@
|
|||||||
<app-side-nav-companion-bar></app-side-nav-companion-bar>
|
<app-side-nav-companion-bar></app-side-nav-companion-bar>
|
||||||
|
|
||||||
<ng-container *transloco="let t; read: 'dashboard'">
|
<ng-container *transloco="let t; read: 'dashboard'">
|
||||||
<ng-container *ngIf="libraries$ | async as libraries">
|
@if (libraries$ | async; as libraries) {
|
||||||
<ng-container *ngIf="libraries.length === 0">
|
@if (libraries.length === 0) {
|
||||||
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
|
@if (isAdmin$ | async; as isAdmin) {
|
||||||
<div *ngIf="isAdmin" class="d-flex justify-content-center">
|
<div class="mt-3">
|
||||||
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a></p>
|
@if (isAdmin) {
|
||||||
</div>
|
<div class="d-flex justify-content-center">
|
||||||
<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>
|
||||||
<p>{{t('not-granted')}}</p>
|
</div>
|
||||||
</div>
|
} @else {
|
||||||
</div>
|
<div class="d-flex justify-content-center">
|
||||||
</ng-container>
|
<p>{{t('not-granted')}}</p>
|
||||||
</ng-container>
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container *ngFor="let stream of streams">
|
@for(stream of streams; track stream.id) {
|
||||||
<ng-container [ngSwitch]="stream.streamType">
|
@switch (stream.streamType) {
|
||||||
<ng-container *ngSwitchCase="StreamType.OnDeck" [ngTemplateOutlet]="onDeck" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
|
@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-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)">
|
<app-carousel-reel [items]="data" [title]="stream.name" (sectionClick)="handleFilterSectionClick(stream)">
|
||||||
<ng-template #carouselItem let-item>
|
<ng-template #carouselItem let-item>
|
||||||
<app-series-card [data]="item" [libraryId]="item.libraryId"
|
<app-series-card [data]="item" [libraryId]="item.libraryId"
|
||||||
(reload)="reloadStream(item.id)" (dataChanged)="reloadStream(item.id)"></app-series-card>
|
(reload)="reloadStream(item.id)" (dataChanged)="reloadStream(item.id)"></app-series-card>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
</ng-container>
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #onDeck let-stream: DashboardStream>
|
<ng-template #onDeck let-stream: DashboardStream>
|
||||||
<ng-container *ngIf="(stream.api | async) as data">
|
@if(stream.api | async; as data) {
|
||||||
<app-carousel-reel [items]="data" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
|
<app-carousel-reel [items]="data" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick(StreamId.OnDeck)">
|
||||||
<ng-template #carouselItem let-item>
|
<ng-template #carouselItem let-item>
|
||||||
<app-series-card [data]="item" [libraryId]="item.libraryId" [isOnDeck]="true"
|
<app-series-card [data]="item" [libraryId]="item.libraryId" [isOnDeck]="true"
|
||||||
(reload)="reloadStream(stream.id)" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
(reload)="reloadStream(stream.id)" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
</ng-container>
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #recentlyUpdated let-stream: DashboardStream>
|
<ng-template #recentlyUpdated let-stream: DashboardStream>
|
||||||
<ng-container *ngIf="(stream.api | async) as data">
|
@if(stream.api | async; as data) {
|
||||||
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
|
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick(StreamId.RecentlyUpdatedSeries)">
|
||||||
<ng-template #carouselItem let-item>
|
<ng-template #carouselItem let-item>
|
||||||
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
||||||
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
|
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
</ng-container>
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #newlyUpdated let-stream: DashboardStream>
|
<ng-template #newlyUpdated let-stream: DashboardStream>
|
||||||
<ng-container *ngIf="(stream.api | async) as data">
|
@if(stream.api | async; as data) {
|
||||||
<app-carousel-reel [items]="data" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick('newly added series')">
|
<app-carousel-reel [items]="data" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick(StreamId.NewlyAddedSeries)">
|
||||||
<ng-template #carouselItem let-item>
|
<ng-template #carouselItem let-item>
|
||||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
</ng-container>
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #moreInGenre let-stream: DashboardStream>
|
<ng-template #moreInGenre let-stream: DashboardStream>
|
||||||
<ng-container *ngIf="(stream.api | async) as data">
|
@if(stream.api | async; as data) {
|
||||||
<app-carousel-reel [items]="data" [title]="t('more-in-genre-title', {genre: genre?.title})" (sectionClick)="handleSectionClick('more in genre')">
|
<app-carousel-reel [items]="data" [title]="t('more-in-genre-title', {genre: genre?.title})" (sectionClick)="handleSectionClick(StreamId.MoreInGenre)">
|
||||||
<ng-template #carouselItem let-item>
|
<ng-template #carouselItem let-item>
|
||||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="reloadStream(stream.id)"></app-series-card>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
</ng-container>
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
}
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<app-loading [loading]="isLoadingDashboard || (streamCount !== streamsLoaded)"></app-loading>
|
<app-loading [loading]="isLoadingDashboard || (streamCount !== streamsLoaded)"></app-loading>
|
||||||
</ng-container>
|
</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 {Title} from '@angular/platform-browser';
|
||||||
import {Router, RouterLink} from '@angular/router';
|
import {Router, RouterLink} from '@angular/router';
|
||||||
import {Observable, of, ReplaySubject, Subject, switchMap} from 'rxjs';
|
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 {CardItemComponent} from '../../cards/card-item/card-item.component';
|
||||||
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
|
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
|
||||||
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.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 {
|
import {
|
||||||
SideNavCompanionBarComponent
|
SideNavCompanionBarComponent
|
||||||
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
} 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 {DashboardStream} from "../../_models/dashboard/dashboard-stream";
|
||||||
import {StreamType} from "../../_models/dashboard/stream-type.enum";
|
import {StreamType} from "../../_models/dashboard/stream-type.enum";
|
||||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
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({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
@ -37,8 +47,8 @@ import {LoadingComponent} from "../../shared/loading/loading.component";
|
|||||||
styleUrls: ['./dashboard.component.scss'],
|
styleUrls: ['./dashboard.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent,
|
imports: [SideNavCompanionBarComponent, RouterLink, CarouselReelComponent, SeriesCardComponent,
|
||||||
CardItemComponent, AsyncPipe, TranslocoDirective, NgSwitchCase, NgSwitch, NgForOf, NgTemplateOutlet, LoadingComponent],
|
CardItemComponent, AsyncPipe, TranslocoDirective, NgForOf, NgTemplateOutlet, LoadingComponent],
|
||||||
})
|
})
|
||||||
export class DashboardComponent implements OnInit {
|
export class DashboardComponent implements OnInit {
|
||||||
|
|
||||||
@ -55,6 +65,8 @@ export class DashboardComponent implements OnInit {
|
|||||||
private readonly messageHub = inject(MessageHubService);
|
private readonly messageHub = inject(MessageHubService);
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly dashboardService = inject(DashboardService);
|
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))
|
libraries$: Observable<Library[]> = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef))
|
||||||
isLoadingDashboard = true;
|
isLoadingDashboard = true;
|
||||||
@ -73,6 +85,7 @@ export class DashboardComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
|
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
|
||||||
protected readonly StreamType = StreamType;
|
protected readonly StreamType = StreamType;
|
||||||
|
protected readonly StreamId = StreamId;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadDashboard();
|
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(
|
this.isAdmin$ = this.accountService.currentUser$.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
map(user => (user && this.accountService.hasAdminRole(user)) || false),
|
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);
|
await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSectionClick(sectionTitle: string) {
|
handleSectionClick(streamId: StreamId) {
|
||||||
if (sectionTitle.toLowerCase() === 'recently updated series') {
|
if (streamId === StreamId.RecentlyUpdatedSeries) {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params['page'] = 1;
|
params['page'] = 1;
|
||||||
params['title'] = 'Recently Updated';
|
params['title'] = translate('dashboard.recently-updated-title');
|
||||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||||
if (filter.sortOptions) {
|
if (filter.sortOptions) {
|
||||||
filter.sortOptions.sortField = SortField.LastChapterAdded;
|
filter.sortOptions.sortField = SortField.LastChapterAdded;
|
||||||
filter.sortOptions.isAscending = false;
|
filter.sortOptions.isAscending = false;
|
||||||
}
|
}
|
||||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
||||||
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
} else if (streamId === StreamId.OnDeck) {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params['page'] = 1;
|
params['page'] = 1;
|
||||||
params['title'] = translate('dashboard.on-deck-title');
|
params['title'] = translate('dashboard.on-deck-title');
|
||||||
@ -210,7 +231,7 @@ export class DashboardComponent implements OnInit {
|
|||||||
filter.sortOptions.isAscending = false;
|
filter.sortOptions.isAscending = false;
|
||||||
}
|
}
|
||||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
||||||
} else if (sectionTitle.toLowerCase() === 'newly added series') {
|
} else if (streamId === StreamId.NewlyAddedSeries) {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params['page'] = 1;
|
params['page'] = 1;
|
||||||
params['title'] = translate('dashboard.recently-added-title');
|
params['title'] = translate('dashboard.recently-added-title');
|
||||||
@ -220,10 +241,10 @@ export class DashboardComponent implements OnInit {
|
|||||||
filter.sortOptions.isAscending = false;
|
filter.sortOptions.isAscending = false;
|
||||||
}
|
}
|
||||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
||||||
} else if (sectionTitle.toLowerCase() === 'more in genre') {
|
} else if (streamId === StreamId.MoreInGenre) {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params['page'] = 1;
|
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();
|
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||||
filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains});
|
filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains});
|
||||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
|
||||||
|
@ -132,6 +132,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@if(activeDownloads.length > 1) {
|
||||||
|
<li class="list-group-item dark-menu-item">{{activeDownloads.length}} downloads in Queue</li>
|
||||||
|
}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|
||||||
|
@ -61,16 +61,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
activeEvents: number = 0;
|
activeEvents: number = 0;
|
||||||
|
|
||||||
debugMode: boolean = false;
|
debugMode: boolean = true;
|
||||||
|
|
||||||
|
protected readonly EVENTS = EVENTS;
|
||||||
|
|
||||||
get EVENTS() {
|
public readonly downloadService = inject(DownloadService);
|
||||||
return EVENTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
|
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
|
||||||
private accountService: AccountService, private confirmService: ConfirmService,
|
private accountService: AccountService, private confirmService: ConfirmService,
|
||||||
private readonly cdRef: ChangeDetectorRef, public downloadService: DownloadService) {
|
private readonly cdRef: ChangeDetectorRef) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component, DestroyRef,
|
||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
OnInit,
|
OnInit,
|
||||||
@ -20,6 +20,7 @@ import {NgxStarsModule} from "ngx-stars";
|
|||||||
import {ThemeService} from "../../../_services/theme.service";
|
import {ThemeService} from "../../../_services/theme.service";
|
||||||
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||||
import {ImageComponent} from "../../../shared/image/image.component";
|
import {ImageComponent} from "../../../shared/image/image.component";
|
||||||
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-external-rating',
|
selector: 'app-external-rating',
|
||||||
@ -31,28 +32,31 @@ import {ImageComponent} from "../../../shared/image/image.component";
|
|||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None
|
||||||
})
|
})
|
||||||
export class ExternalRatingComponent implements OnInit {
|
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 cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly seriesService = inject(SeriesService);
|
private readonly seriesService = inject(SeriesService);
|
||||||
private readonly accountService = inject(AccountService);
|
private readonly accountService = inject(AccountService);
|
||||||
private readonly themeService = inject(ThemeService);
|
private readonly themeService = inject(ThemeService);
|
||||||
public readonly utilityService = inject(UtilityService);
|
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> = [];
|
ratings: Array<Rating> = [];
|
||||||
isLoading: boolean = false;
|
isLoading: boolean = false;
|
||||||
overallRating: number = -1;
|
overallRating: number = -1;
|
||||||
|
|
||||||
starColor = this.themeService.getCssVariable('--rating-star-color');
|
starColor = this.themeService.getCssVariable('--rating-star-color');
|
||||||
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
|
||||||
this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore);
|
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;
|
if (!res) return;
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
@ -74,6 +78,4 @@ export class ExternalRatingComponent implements OnInit {
|
|||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly Breakpoint = Breakpoint;
|
|
||||||
}
|
}
|
||||||
|
@ -105,22 +105,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-auto ms-2 d-none d-md-block" *ngIf="isAdmin || hasDownloadingRole">
|
<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">
|
@if (download$ | async; as download) {
|
||||||
<ng-container *ngIf="downloadInProgress; else notDownloading">
|
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null">
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
<ng-container *ngIf="download !== null; else notDownloading">
|
||||||
<span class="visually-hidden">{{t('downloading-status')}}</span>
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
</ng-container>
|
<span class="visually-hidden">{{t('downloading-status')}}</span>
|
||||||
<ng-template #notDownloading>
|
</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>
|
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||||
</ng-template>
|
</button>
|
||||||
</button>
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="seriesMetadata" class="mt-2">
|
|
||||||
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
@if (seriesMetadata) {
|
||||||
[libraryType]="libraryType"
|
<div class="mt-2">
|
||||||
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
||||||
</div>
|
[libraryType]="libraryType"
|
||||||
|
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata?.summary?.length === 0}">
|
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata?.summary?.length === 0}">
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AsyncPipe,
|
||||||
DecimalPipe,
|
DecimalPipe,
|
||||||
DOCUMENT,
|
DOCUMENT,
|
||||||
NgClass,
|
NgClass,
|
||||||
@ -42,13 +43,13 @@ import {
|
|||||||
NgbTooltip
|
NgbTooltip
|
||||||
} from '@ng-bootstrap/ng-bootstrap';
|
} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {ToastrService} from 'ngx-toastr';
|
import {ToastrService} from 'ngx-toastr';
|
||||||
import {catchError, forkJoin, of} from 'rxjs';
|
import {catchError, forkJoin, Observable, of} from 'rxjs';
|
||||||
import {take} from 'rxjs/operators';
|
import {filter, map, take} from 'rxjs/operators';
|
||||||
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
||||||
import {CardDetailDrawerComponent} from 'src/app/cards/card-detail-drawer/card-detail-drawer.component';
|
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 {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 {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 {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||||
import {Chapter} from 'src/app/_models/chapter';
|
import {Chapter} from 'src/app/_models/chapter';
|
||||||
import {Device} from 'src/app/_models/device/device';
|
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 {NextExpectedChapter} from "../../../_models/series-detail/next-expected-chapter";
|
||||||
import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-expected-card.component";
|
import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-expected-card.component";
|
||||||
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
||||||
|
import {MetadataService} from "../../../_services/metadata.service";
|
||||||
|
|
||||||
interface RelatedSeriesPair {
|
interface RelatedSeriesPair {
|
||||||
series: Series;
|
series: Series;
|
||||||
@ -126,19 +128,22 @@ interface StoryLineItem {
|
|||||||
isChapter: boolean;
|
isChapter: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KavitaPlusSupportedLibraryTypes = [LibraryType.Manga, LibraryType.Book];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-series-detail',
|
selector: 'app-series-detail',
|
||||||
templateUrl: './series-detail.component.html',
|
templateUrl: './series-detail.component.html',
|
||||||
styleUrls: ['./series-detail.component.scss'],
|
styleUrls: ['./series-detail.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
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 {
|
export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly seriesService = inject(SeriesService);
|
private readonly seriesService = inject(SeriesService);
|
||||||
|
private readonly metadataService = inject(MetadataService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly modalService = inject(NgbModal);
|
private readonly modalService = inject(NgbModal);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
@ -261,6 +266,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
|
|
||||||
user: User | undefined;
|
user: User | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the download we get from download service.
|
||||||
|
*/
|
||||||
|
download$: Observable<DownloadEvent | null> | null = null;
|
||||||
|
|
||||||
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
||||||
if (this.series === undefined) {
|
if (this.series === undefined) {
|
||||||
return;
|
return;
|
||||||
@ -368,6 +378,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
return;
|
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 => {
|
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
|
||||||
if (event.event === EVENTS.SeriesRemoved) {
|
if (event.event === EVENTS.SeriesRemoved) {
|
||||||
const seriesRemovedEvent = event.payload as SeriesRemovedEvent;
|
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;
|
if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return;
|
||||||
this.seriesService.getNextExpectedChapterDate(seriesId).subscribe(date => {
|
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.nextExpectedChapter = date;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
@ -563,6 +585,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
});
|
});
|
||||||
this.setContinuePoint();
|
this.setContinuePoint();
|
||||||
|
|
||||||
|
if (KavitaPlusSupportedLibraryTypes.includes(this.libraryType) && loadExternal) {
|
||||||
|
this.loadPlusMetadata(this.seriesId);
|
||||||
|
}
|
||||||
|
|
||||||
forkJoin({
|
forkJoin({
|
||||||
libType: this.libraryService.getLibraryType(this.libraryId),
|
libType: this.libraryService.getLibraryType(this.libraryId),
|
||||||
series: this.seriesService.getSeries(seriesId)
|
series: this.seriesService.getSeries(seriesId)
|
||||||
@ -570,10 +596,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
this.libraryType = results.libType;
|
this.libraryType = results.libType;
|
||||||
this.series = results.series;
|
this.series = results.series;
|
||||||
|
|
||||||
if (this.libraryType !== LibraryType.Comic && loadExternal) {
|
|
||||||
this.loadReviews(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
|
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
|
||||||
|
|
||||||
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
|
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
|
||||||
@ -670,23 +692,37 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRecommendations() {
|
// loadRecommendations() {
|
||||||
this.seriesService.getRecommendationsForSeries(this.seriesId).subscribe(rec => {
|
// this.seriesService.getRecommendationsForSeries(this.seriesId).subscribe(rec => {
|
||||||
rec.ownedSeries.map(r => {
|
// 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.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.hasRecommendations = this.combinedRecs.length > 0;
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
loadReviews() {
|
||||||
loadReviews(loadRecs: boolean = false) {
|
|
||||||
this.seriesService.getReviews(this.seriesId).subscribe(reviews => {
|
this.seriesService.getReviews(this.seriesId).subscribe(reviews => {
|
||||||
this.reviews = [...reviews];
|
this.reviews = [...reviews];
|
||||||
if (loadRecs) {
|
|
||||||
this.loadRecommendations(); // We do this as first load will spam 3 calls on API layer
|
|
||||||
}
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -829,7 +865,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||||||
modalRef.componentInstance.series = this.series;
|
modalRef.componentInstance.series = this.series;
|
||||||
modalRef.closed.subscribe((closeResult: {success: boolean}) => {
|
modalRef.closed.subscribe((closeResult: {success: boolean}) => {
|
||||||
if (closeResult.success) {
|
if (closeResult.success) {
|
||||||
this.loadReviews();
|
this.loadReviews(); // TODO: Ensure reviews get updated here
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
</div>
|
</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>
|
<ng-template #itemTemplate let-item>
|
||||||
<app-external-rating [seriesId]="series.id" [userRating]="series.userRating" [hasUserRated]="series.hasUserRated" [libraryType]="libraryType"></app-external-rating>
|
<app-external-rating [seriesId]="series.id" [userRating]="series.userRating" [hasUserRated]="series.hasUserRated" [libraryType]="libraryType"></app-external-rating>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -48,6 +48,18 @@ import {ImageComponent} from "../../../shared/image/image.component";
|
|||||||
})
|
})
|
||||||
export class SeriesMetadataDetailComponent implements OnChanges {
|
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}) seriesMetadata!: SeriesMetadata;
|
||||||
@Input({required: true}) libraryType!: LibraryType;
|
@Input({required: true}) libraryType!: LibraryType;
|
||||||
@Input() hasReadingProgress: boolean = false;
|
@Input() hasReadingProgress: boolean = false;
|
||||||
@ -60,23 +72,11 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
|||||||
isCollapsed: boolean = true;
|
isCollapsed: boolean = true;
|
||||||
hasExtendedProperties: boolean = false;
|
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
|
* Html representation of Series Summary
|
||||||
*/
|
*/
|
||||||
seriesSummary: string = '';
|
seriesSummary: string = '';
|
||||||
|
|
||||||
protected FilterField = FilterField;
|
|
||||||
protected LibraryType = LibraryType;
|
|
||||||
protected MangaFormat = MangaFormat;
|
|
||||||
protected TagBadgeCursor = TagBadgeCursor;
|
|
||||||
|
|
||||||
get WebLinks() {
|
get WebLinks() {
|
||||||
if (this.seriesMetadata?.webLinks === '') return [];
|
if (this.seriesMetadata?.webLinks === '') return [];
|
||||||
return this.seriesMetadata?.webLinks.split(',') || [];
|
return this.seriesMetadata?.webLinks.split(',') || [];
|
||||||
@ -121,6 +121,4 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
|||||||
navigate(basePage: string, id: number) {
|
navigate(basePage: string, id: number) {
|
||||||
this.router.navigate([basePage, id]);
|
this.router.navigate([basePage, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly Breakpoint = Breakpoint;
|
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
tap,
|
tap,
|
||||||
finalize,
|
finalize,
|
||||||
of,
|
of,
|
||||||
filter,
|
filter, Subject,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { download, Download } from '../_models/download';
|
import { download, Download } from '../_models/download';
|
||||||
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
|
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 {translate} from "@ngneat/transloco";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {SAVER, Saver} from "../../_providers/saver.provider";
|
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;
|
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 DownloadEntity = Series | Volume | Chapter | PageBookmark[] | undefined;
|
||||||
|
|
||||||
|
export type QueueableDownloadType = Chapter | Volume;
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -68,14 +73,33 @@ export class DownloadService {
|
|||||||
public SIZE_WARNING = 104_857_600;
|
public SIZE_WARNING = 104_857_600;
|
||||||
|
|
||||||
private downloadsSource: BehaviorSubject<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
|
private downloadsSource: BehaviorSubject<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
|
||||||
|
/**
|
||||||
|
* Active Downloads
|
||||||
|
*/
|
||||||
public activeDownloads$ = this.downloadsSource.asObservable();
|
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 destroyRef = inject(DestroyRef);
|
||||||
private readonly confirmService = inject(ConfirmService);
|
private readonly confirmService = inject(ConfirmService);
|
||||||
private readonly accountService = inject(AccountService);
|
private readonly accountService = inject(AccountService);
|
||||||
private readonly httpClient = inject(HttpClient);
|
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
|
* @param downloadEntity
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
downloadSubtitle(downloadEntityType: DownloadEntityType, downloadEntity: DownloadEntity | undefined) {
|
downloadSubtitle(downloadEntityType: DownloadEntityType | undefined, downloadEntity: DownloadEntity | undefined) {
|
||||||
switch (downloadEntityType) {
|
switch (downloadEntityType) {
|
||||||
case 'series':
|
case 'series':
|
||||||
return (downloadEntity as Series).name;
|
return (downloadEntity as Series).name;
|
||||||
@ -97,6 +121,7 @@ export class DownloadService {
|
|||||||
case 'logs':
|
case 'logs':
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -117,10 +142,12 @@ export class DownloadService {
|
|||||||
case 'volume':
|
case 'volume':
|
||||||
sizeCheckCall = this.downloadVolumeSize((entity as Volume).id);
|
sizeCheckCall = this.downloadVolumeSize((entity as Volume).id);
|
||||||
downloadCall = this.downloadVolume(entity as Volume);
|
downloadCall = this.downloadVolume(entity as Volume);
|
||||||
|
//this.enqueueDownload(entity as Volume);
|
||||||
break;
|
break;
|
||||||
case 'chapter':
|
case 'chapter':
|
||||||
sizeCheckCall = this.downloadChapterSize((entity as Chapter).id);
|
sizeCheckCall = this.downloadChapterSize((entity as Chapter).id);
|
||||||
downloadCall = this.downloadChapter(entity as Chapter);
|
downloadCall = this.downloadChapter(entity as Chapter);
|
||||||
|
//this.enqueueDownload(entity as Chapter);
|
||||||
break;
|
break;
|
||||||
case 'bookmark':
|
case 'bookmark':
|
||||||
sizeCheckCall = of(0);
|
sizeCheckCall = of(0);
|
||||||
@ -145,8 +172,10 @@ export class DownloadService {
|
|||||||
})
|
})
|
||||||
).pipe(filter(wantsToDownload => {
|
).pipe(filter(wantsToDownload => {
|
||||||
return wantsToDownload;
|
return wantsToDownload;
|
||||||
}), switchMap(() => {
|
}),
|
||||||
return downloadCall.pipe(
|
filter(_ => downloadCall !== undefined),
|
||||||
|
switchMap(() => {
|
||||||
|
return (downloadCall || of(undefined)).pipe(
|
||||||
tap((d) => {
|
tap((d) => {
|
||||||
if (callback) callback(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) {
|
private downloadSeries(series: Series) {
|
||||||
|
|
||||||
|
// TODO: Call backend for all the volumes and loose leaf chapters then enqueque them all
|
||||||
|
|
||||||
const downloadType = 'series';
|
const downloadType = 'series';
|
||||||
const subtitle = this.downloadSubtitle(downloadType, series);
|
const subtitle = this.downloadSubtitle(downloadType, series);
|
||||||
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
|
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
|
||||||
@ -227,38 +289,42 @@ export class DownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private downloadChapter(chapter: Chapter) {
|
private downloadChapter(chapter: Chapter) {
|
||||||
const downloadType = 'chapter';
|
return this.downloadEntity(chapter);
|
||||||
const subtitle = this.downloadSubtitle(downloadType, chapter);
|
|
||||||
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
// const downloadType = 'chapter';
|
||||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
// const subtitle = this.downloadSubtitle(downloadType, chapter);
|
||||||
).pipe(
|
// return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
||||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
// {observe: 'events', responseType: 'blob', reportProgress: true}
|
||||||
download((blob, filename) => {
|
// ).pipe(
|
||||||
this.save(blob, decodeURIComponent(filename));
|
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||||
}),
|
// download((blob, filename) => {
|
||||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)),
|
// this.save(blob, decodeURIComponent(filename));
|
||||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
// }),
|
||||||
);
|
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)),
|
||||||
|
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
private downloadVolume(volume: Volume): Observable<Download> {
|
private downloadVolume(volume: Volume) {
|
||||||
const downloadType = 'volume';
|
return this.downloadEntity(volume);
|
||||||
const subtitle = this.downloadSubtitle(downloadType, volume);
|
// const downloadType = 'volume';
|
||||||
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
|
// const subtitle = this.downloadSubtitle(downloadType, volume);
|
||||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
// return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
|
||||||
).pipe(
|
// {observe: 'events', responseType: 'blob', reportProgress: true}
|
||||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
// ).pipe(
|
||||||
download((blob, filename) => {
|
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||||
this.save(blob, decodeURIComponent(filename));
|
// download((blob, filename) => {
|
||||||
}),
|
// this.save(blob, decodeURIComponent(filename));
|
||||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)),
|
// }),
|
||||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)),
|
||||||
);
|
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
private async confirmSize(size: number, entityType: DownloadEntityType) {
|
private async confirmSize(size: number, entityType: DownloadEntityType) {
|
||||||
return (size < this.SIZE_WARNING ||
|
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[]) {
|
private downloadBookmarks(bookmarks: PageBookmark[]) {
|
||||||
@ -276,4 +342,60 @@ export class DownloadService {
|
|||||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
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):
|
case (Action.AnalyzeFiles):
|
||||||
await this.actionService.analyzeFiles(library);
|
await this.actionService.analyzeFiles(library);
|
||||||
break;
|
break;
|
||||||
|
case (Action.Delete):
|
||||||
|
await this.actionService.deleteLibrary(library);
|
||||||
|
break;
|
||||||
case (Action.Edit):
|
case (Action.Edit):
|
||||||
this.actionService.editLibrary(library, () => window.scrollTo(0, 0));
|
this.actionService.editLibrary(library, () => window.scrollTo(0, 0));
|
||||||
break;
|
break;
|
||||||
|
@ -3,7 +3,18 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<div class="container-fluid row mb-2">
|
<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">
|
<div class="col-1 text-end">
|
||||||
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? t('edit') : t('cancel')}}</button>
|
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? t('edit') : t('cancel')}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
.error {
|
.error {
|
||||||
color: var(--error-color);
|
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="container-fluid row mb-2">
|
||||||
<div class="col-10 col-sm-11">
|
<div class="col-10 col-sm-11">
|
||||||
<h4 id="email-card">{{t('email-label')}}
|
<h4 id="email-card">{{t('email-label')}}
|
||||||
<ng-container *ngIf="!emailConfirmed">
|
@if(emailConfirmed) {
|
||||||
<i class="fa-solid fa-circle ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('email-not-confirmed')"></i>
|
<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>
|
<span class="visually-hidden">{{t('email-not-confirmed')}}</span>
|
||||||
</ng-container>
|
}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-1 text-end">
|
<div class="col-1 text-end">
|
||||||
|
@ -3,3 +3,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
@ -245,6 +245,7 @@
|
|||||||
"email-label": "{{common.email}}",
|
"email-label": "{{common.email}}",
|
||||||
"current-password-label": "Current Password",
|
"current-password-label": "Current Password",
|
||||||
"email-not-confirmed": "This email is not confirmed",
|
"email-not-confirmed": "This email is not confirmed",
|
||||||
|
"email-confirmed": "This email is confirmed",
|
||||||
"email-updated-title": "Email Updated",
|
"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.",
|
"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",
|
"setup-user-account": "Setup user's account",
|
||||||
@ -722,6 +723,7 @@
|
|||||||
|
|
||||||
"series-metadata-detail": {
|
"series-metadata-detail": {
|
||||||
"links-title": "Links",
|
"links-title": "Links",
|
||||||
|
"rating-title": "Ratings",
|
||||||
"genres-title": "Genres",
|
"genres-title": "Genres",
|
||||||
"tags-title": "Tags",
|
"tags-title": "Tags",
|
||||||
"collections-title": "{{side-nav.collections}}",
|
"collections-title": "{{side-nav.collections}}",
|
||||||
@ -2000,7 +2002,8 @@
|
|||||||
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
|
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
|
||||||
"smart-filter-deleted": "Smart Filter Deleted",
|
"smart-filter-deleted": "Smart Filter Deleted",
|
||||||
"smart-filter-updated": "Created/Updated smart filter",
|
"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": {
|
"actionable": {
|
||||||
|
@ -13,6 +13,9 @@
|
|||||||
--primary-color-scrollbar: rgba(74,198,148,0.75);
|
--primary-color-scrollbar: rgba(74,198,148,0.75);
|
||||||
--text-muted-color: lightgrey;
|
--text-muted-color: lightgrey;
|
||||||
|
|
||||||
|
/* New Color scheme */
|
||||||
|
--secondary-color: #212328;
|
||||||
|
|
||||||
/* Meta and Globals */
|
/* Meta and Globals */
|
||||||
--theme-color: #000000;
|
--theme-color: #000000;
|
||||||
--color-scheme: dark;
|
--color-scheme: dark;
|
||||||
|
68
openapi.json
68
openapi.json
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.7.12.2"
|
"version": "0.7.12.3"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -3538,6 +3538,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Metadata"
|
"Metadata"
|
||||||
],
|
],
|
||||||
|
"summary": "Returns all languages Kavita can accept",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"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}": {
|
"/api/Opds/{apiKey}": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -17652,6 +17694,30 @@
|
|||||||
"additionalProperties": false,
|
"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."
|
"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": {
|
"SeriesDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user