diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index 2fffc47da..fa21bd12d 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -233,7 +233,7 @@ public class DownloadController : BaseApiController MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), $"Downloading {filename}", 1F)); - return PhysicalFile(filePath, DefaultContentType, System.Web.HttpUtility.UrlEncode(filename), true); + return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(filename), true); } } diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs index 08a7789ad..29d4824a7 100644 --- a/API/Controllers/LicenseController.cs +++ b/API/Controllers/LicenseController.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using API.Constants; using API.Data; -using API.DTOs.Account; using API.DTOs.License; using API.Entities.Enums; using API.Extensions; @@ -20,7 +19,8 @@ public class LicenseController( IUnitOfWork unitOfWork, ILogger logger, ILicenseService licenseService, - ILocalizationService localizationService) + ILocalizationService localizationService, + ITaskScheduler taskScheduler) : BaseApiController { /// @@ -31,7 +31,12 @@ public class LicenseController( [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] public async Task> HasValidLicense(bool forceCheck = false) { - return Ok(await licenseService.HasActiveLicense(forceCheck)); + var ret = await licenseService.HasActiveLicense(forceCheck); + if (ret) + { + await taskScheduler.ScheduleKavitaPlusTasks(); + } + return Ok(ret); } /// @@ -57,6 +62,7 @@ public class LicenseController( setting.Value = null; unitOfWork.SettingsRepository.Update(setting); await unitOfWork.CommitAsync(); + await taskScheduler.ScheduleKavitaPlusTasks(); return Ok(); } @@ -82,6 +88,7 @@ public class LicenseController( try { await licenseService.AddLicense(dto.License.Trim(), dto.Email.Trim(), dto.DiscordId); + await taskScheduler.ScheduleKavitaPlusTasks(); } catch (Exception ex) { diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index b3dbb8a01..f87456baf 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -8,9 +8,11 @@ using API.Data; using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; +using API.DTOs.SeriesDetail; using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Plus; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Mvc; @@ -18,17 +20,10 @@ namespace API.Controllers; #nullable enable -public class MetadataController : BaseApiController +public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILicenseService licenseService, + IRatingService ratingService, IReviewService reviewService, IRecommendationService recommendationService, IExternalMetadataService metadataService) + : BaseApiController { - private readonly IUnitOfWork _unitOfWork; - private readonly ILocalizationService _localizationService; - - public MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService) - { - _unitOfWork = unitOfWork; - _localizationService = localizationService; - } - /// /// Fetches genres from the instance /// @@ -41,10 +36,10 @@ public class MetadataController : BaseApiController var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { - return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId())); + return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId())); } - return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId())); + return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId())); } /// @@ -57,8 +52,8 @@ public class MetadataController : BaseApiController public async Task>> GetAllPeople(PersonRole? role) { return role.HasValue ? - Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) : - Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId())); + Ok(await unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) : + Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId())); } /// @@ -73,9 +68,9 @@ public class MetadataController : BaseApiController var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { - return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId())); + return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId())); } - return Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId())); + return Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId())); } /// @@ -90,9 +85,9 @@ public class MetadataController : BaseApiController var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { - return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId())); + return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId())); } - return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId())); + return Ok(await unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId())); } /// @@ -108,7 +103,7 @@ public class MetadataController : BaseApiController var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { - return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids)); + return Ok(await unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids)); } return Ok(Enum.GetValues().Select(t => new AgeRatingDto() @@ -131,7 +126,7 @@ public class MetadataController : BaseApiController var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids is {Count: > 0}) { - return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids)); + return Ok(unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids)); } return Ok(Enum.GetValues().Select(t => new PublicationStatusDto() @@ -152,10 +147,13 @@ public class MetadataController : BaseApiController public async Task>> GetAllLanguages(string? libraryIds) { var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); - return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids)); + return Ok(await unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids)); } - + /// + /// Returns all languages Kavita can accept + /// + /// [HttpGet("all-languages")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] public IEnumerable GetAllValidLanguages() @@ -177,9 +175,38 @@ public class MetadataController : BaseApiController [HttpGet("chapter-summary")] public async Task> GetChapterSummary(int chapterId) { - if (chapterId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + if (chapterId <= 0) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); return Ok(chapter.Summary); } + + /// + /// Fetches the details needed from Kavita+ for Series Detail page + /// + /// + /// + [HttpGet("series-detail-plus")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])] + public async Task> GetKavitaPlusSeriesDetailData(int seriesId) + { + var seriesDetail = new SeriesDetailPlusDto(); + if (!await licenseService.HasActiveLicense()) + { + seriesDetail.Recommendations = null; + seriesDetail.Ratings = Enumerable.Empty(); + 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); + + } } diff --git a/API/Controllers/RatingController.cs b/API/Controllers/RatingController.cs index e82cb1fbd..f3cd3180f 100644 --- a/API/Controllers/RatingController.cs +++ b/API/Controllers/RatingController.cs @@ -44,26 +44,14 @@ public class RatingController : BaseApiController /// /// [HttpGet] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])] public async Task>> GetRating(int seriesId) { - if (!await _licenseService.HasActiveLicense()) { return Ok(Enumerable.Empty()); } - - var cacheKey = CacheKey + seriesId; - var results = await _cacheProvider.GetAsync>(cacheKey); - if (results.HasValue) - { - return Ok(results.Value); - } - - var ratings = await _ratingService.GetRatings(seriesId); - await _cacheProvider.SetAsync(cacheKey, ratings, TimeSpan.FromHours(24)); - _logger.LogDebug("Caching external rating for {Key}", cacheKey); - return Ok(ratings); + return Ok(await _ratingService.GetRatings(seriesId)); } [HttpGet("overall")] diff --git a/API/Controllers/ReviewController.cs b/API/Controllers/ReviewController.cs index 63ff20407..0e8f6608d 100644 --- a/API/Controllers/ReviewController.cs +++ b/API/Controllers/ReviewController.cs @@ -51,78 +51,10 @@ public class ReviewController : BaseApiController /// /// [HttpGet] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = ["seriesId"])] public async Task>> GetReviews(int seriesId) { - var userId = User.GetUserId(); - var username = User.GetUsername(); - var userRatings = (await _unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, userId)) - .Where(r => !string.IsNullOrEmpty(r.Body)) - .OrderByDescending(review => review.Username.Equals(username) ? 1 : 0) - .ToList(); - if (!await _licenseService.HasActiveLicense()) - { - return Ok(userRatings); - } - - var cacheKey = CacheKey + seriesId; - IList externalReviews; - - var result = await _cacheProvider.GetAsync>(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 SelectSpectrumOfReviews(IList reviews) - { - IList externalReviews; - var totalReviews = reviews.Count; - - if (totalReviews > 10) - { - var stepSize = Math.Max((totalReviews - 4) / 8, 1); - - var selectedReviews = new List() - { - reviews[0], - reviews[1], - }; - for (var i = 2; i < totalReviews - 2; i += stepSize) - { - selectedReviews.Add(reviews[i]); - - if (selectedReviews.Count >= 8) - break; - } - - selectedReviews.Add(reviews[totalReviews - 2]); - selectedReviews.Add(reviews[totalReviews - 1]); - - externalReviews = selectedReviews; - } - else - { - externalReviews = reviews; - } - - return externalReviews; + return Ok(await _reviewService.GetReviewsForSeries(User.GetUserId(), seriesId)); } /// diff --git a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs new file mode 100644 index 000000000..d793342b2 --- /dev/null +++ b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using API.DTOs.Recommendation; + +namespace API.DTOs.SeriesDetail; + +/// +/// All the data from Kavita+ for Series Detail +/// +/// This is what the UI sees, not what the API sends back +public class SeriesDetailPlusDto +{ + public RecommendationDto Recommendations { get; set; } + public IEnumerable Reviews { get; set; } + public IEnumerable Ratings { get; set; } +} diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index cda7fe76e..158dd2a89 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -1,11 +1,16 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; +using API.DTOs; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; +using API.DTOs.SeriesDetail; +using API.Entities; using API.Entities.Enums; +using API.Extensions; using API.Helpers.Builders; using Flurl.Http; using Kavita.Common; @@ -29,9 +34,17 @@ internal class ExternalMetadataIdsDto public MediaFormat? PlusMediaFormat { get; set; } = MediaFormat.Unknown; } +internal class SeriesDetailPlusAPIDto +{ + public IEnumerable Recommendations { get; set; } + public IEnumerable Reviews { get; set; } + public IEnumerable Ratings { get; set; } +} + public interface IExternalMetadataService { Task GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId); + Task GetSeriesDetail(int userId, int seriesId); } public class ExternalMetadataService : IExternalMetadataService @@ -48,6 +61,14 @@ public class ExternalMetadataService : IExternalMetadataService cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); } + /// + /// Retrieves Metadata about a Recommended External Series + /// + /// + /// + /// + /// + /// public async Task GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId) { if (!aniListId.HasValue && !malId.HasValue) @@ -60,6 +81,92 @@ public class ExternalMetadataService : IExternalMetadataService } + public async Task 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(); + + + 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 ProcessRecommendations(Series series, AppUser user, IEnumerable recs) + { + var recDto = new RecommendationDto() + { + ExternalSeries = new List(), + OwnedSeries = new List() + }; + + 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 GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId) { var payload = new ExternalMetadataIdsDto() diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index c00527c2c..9960744e4 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -7,7 +7,6 @@ using API.DTOs.License; using API.Entities.Enums; using EasyCaching.Core; using Flurl.Http; -using Hangfire; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.Extensions.Logging; @@ -24,31 +23,25 @@ internal class RegisterLicenseResponseDto public interface ILicenseService { - Task ValidateLicenseStatus(); + //Task ValidateLicenseStatus(); Task RemoveLicense(); Task AddLicense(string license, string email, string? discordId); Task HasActiveLicense(bool forceCheck = false); + Task HasActiveSubscription(string? license); Task ResetLicense(string license, string email); } -public class LicenseService : ILicenseService +public class LicenseService( + IEasyCachingProviderFactory cachingProviderFactory, + IUnitOfWork unitOfWork, + ILogger logger) + : ILicenseService { - private readonly IEasyCachingProviderFactory _cachingProviderFactory; - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8); public const string Cron = "0 */4 * * *"; private const string CacheKey = "license"; - public LicenseService(IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork, ILogger logger) - { - _cachingProviderFactory = cachingProviderFactory; - _unitOfWork = unitOfWork; - _logger = logger; - } - - /// /// Performs license lookup to API layer /// @@ -77,7 +70,7 @@ public class LicenseService : ILicenseService } catch (Exception e) { - _logger.LogError(e, "An error happened during the request to Kavita+ API"); + logger.LogError(e, "An error happened during the request to Kavita+ API"); throw; } } @@ -115,12 +108,12 @@ public class LicenseService : ILicenseService return response.EncryptedLicense; } - _logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response.ErrorMessage); + logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response.ErrorMessage); throw new KavitaException(response.ErrorMessage); } catch (FlurlHttpException e) { - _logger.LogError(e, "An error happened during the request to Kavita+ API"); + logger.LogError(e, "An error happened during the request to Kavita+ API"); return string.Empty; } } @@ -129,57 +122,41 @@ public class LicenseService : ILicenseService /// Checks licenses and updates cache /// /// Expected to be called at startup and on reoccurring basis - public async Task ValidateLicenseStatus() - { - var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); - try - { - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - if (string.IsNullOrEmpty(license.Value)) { - await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); - return; - } - - _logger.LogInformation("Validating Kavita+ License"); - - await provider.FlushAsync(); - var isValid = await IsLicenseValid(license.Value); - await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout); - - _logger.LogInformation("Validating Kavita+ License - Complete"); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins"); - await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); - BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30)); - } - } - - public async Task RemoveLicense() - { - var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - serverSetting.Value = string.Empty; - _unitOfWork.SettingsRepository.Update(serverSetting); - await _unitOfWork.CommitAsync(); - var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); - await provider.RemoveAsync(CacheKey); - } - - public async Task AddLicense(string license, string email, string? discordId) - { - var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - var lic = await RegisterLicense(license, email, discordId); - if (string.IsNullOrWhiteSpace(lic)) - throw new KavitaException("unable-to-register-k+"); - serverSetting.Value = lic; - _unitOfWork.SettingsRepository.Update(serverSetting); - await _unitOfWork.CommitAsync(); - } + // public async Task ValidateLicenseStatus() + // { + // var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + // try + // { + // var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); + // if (string.IsNullOrEmpty(license.Value)) { + // await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); + // return; + // } + // + // _logger.LogInformation("Validating Kavita+ License"); + // + // await provider.FlushAsync(); + // var isValid = await IsLicenseValid(license.Value); + // await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout); + // + // _logger.LogInformation("Validating Kavita+ License - Complete"); + // } + // catch (Exception ex) + // { + // _logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins"); + // await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); + // BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30)); + // } + // } + /// + /// Checks licenses and updates cache + /// + /// Skip what's in cache + /// public async Task HasActiveLicense(bool forceCheck = false) { - var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); if (!forceCheck) { var cacheValue = await provider.GetAsync(CacheKey); @@ -188,7 +165,7 @@ public class LicenseService : ILicenseService try { - var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); + var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var result = await IsLicenseValid(serverSetting.Value); await provider.FlushAsync(); await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); @@ -196,17 +173,77 @@ public class LicenseService : ILicenseService } catch (Exception ex) { - _logger.LogError(ex, "There was an issue connecting to Kavita+"); + logger.LogError(ex, "There was an issue connecting to Kavita+"); } return false; } + public async Task 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 ResetLicense(string license, string email) { try { - var encryptedLicense = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); + var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var response = await (Configuration.KavitaPlusApiUrl + "/api/license/reset") .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") @@ -225,17 +262,17 @@ public class LicenseService : ILicenseService if (string.IsNullOrEmpty(response)) { - var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); await provider.RemoveAsync(CacheKey); return true; } - _logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response); + logger.LogError("An error happened during the request to Kavita+ API: {ErrorMessage}", response); throw new KavitaException(response); } catch (FlurlHttpException e) { - _logger.LogError(e, "An error happened during the request to Kavita+ API"); + logger.LogError(e, "An error happened during the request to Kavita+ API"); } return false; diff --git a/API/Services/Plus/RatingService.cs b/API/Services/Plus/RatingService.cs index 7701b2326..22674e55c 100644 --- a/API/Services/Plus/RatingService.cs +++ b/API/Services/Plus/RatingService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -10,6 +11,7 @@ using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Helpers.Builders; +using EasyCaching.Core; using Flurl.Http; using Kavita.Common; using Kavita.Common.EnvironmentInfo; @@ -28,25 +30,51 @@ public class RatingService : IRatingService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; + private readonly IEasyCachingProvider _cacheProvider; - public RatingService(IUnitOfWork unitOfWork, ILogger logger) + public const string CacheKey = "rating_"; + + public RatingService(IUnitOfWork unitOfWork, ILogger logger, IEasyCachingProviderFactory cachingProviderFactory) { _unitOfWork = unitOfWork; _logger = logger; FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + + _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings); } + /// + /// Fetches Ratings for a given Series. Will check cache first + /// + /// + /// public async Task> GetRatings(int seriesId) { + var cacheKey = CacheKey + seriesId; + var results = await _cacheProvider.GetAsync>(cacheKey); + if (results.HasValue) + { + return results.Value; + } + var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Chapters | SeriesIncludes.Volumes); // Don't send any ratings back for Comic libraries as Kavita+ doesn't have any providers for that - if (series == null || series.Library.Type == LibraryType.Comic) return ImmutableList.Empty; - return await GetRatings(license.Value, series); + if (series == null || series.Library.Type == LibraryType.Comic) + { + await _cacheProvider.SetAsync(cacheKey, ImmutableList.Empty, TimeSpan.FromHours(24)); + return ImmutableList.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> GetRatings(string license, Series series) diff --git a/API/Services/Plus/RecommendationService.cs b/API/Services/Plus/RecommendationService.cs index 1a6a1b315..22b6a1d05 100644 --- a/API/Services/Plus/RecommendationService.cs +++ b/API/Services/Plus/RecommendationService.cs @@ -40,7 +40,7 @@ public record PlusSeriesDto public int? Year { get; set; } } -internal record MediaRecommendationDto +public record MediaRecommendationDto { public int Rating { get; set; } public IEnumerable RecommendationNames { get; set; } = null!; @@ -126,7 +126,7 @@ public class RecommendationService : IRecommendationService } - private async Task> GetRecommendations(string license, Series series) + protected async Task> GetRecommendations(string license, Series series) { try { diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index d93a93020..16ec91288 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -7,7 +7,6 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs.Filtering; -using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.Entities; using API.Entities.Enums; @@ -78,7 +77,7 @@ public class ScrobblingService : IScrobblingService {MangaDexWeblinkWebsite, 0}, }; - private const int ScrobbleSleepTime = 700; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90) + private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90) private static readonly IList BookProviders = new List() { @@ -425,8 +424,17 @@ public class ScrobblingService : IScrobblingService if (response.ErrorMessage != null && response.ErrorMessage.Contains("Too Many Requests")) { _logger.LogInformation("Hit Too many requests, sleeping to regain requests"); - await Task.Delay(TimeSpan.FromMinutes(1)); - } else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series")) + await Task.Delay(TimeSpan.FromMinutes(5)); + } else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unauthorized")) + { + _logger.LogInformation("Kavita+ responded with Unauthorized. Please check your subscription"); + await _licenseService.HasActiveLicense(true); + throw new KavitaException("Kavita+ responded with Unauthorized. Please check your subscription"); + } else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Access token is invalid")) + { + throw new KavitaException("Access token is invalid"); + } + else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series")) { // Log the Series name and Id in ScrobbleErrors _logger.LogInformation("Kavita+ was unable to match the series"); @@ -615,10 +623,7 @@ public class ScrobblingService : IScrobblingService .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) .Where(e => !errors.Contains(e.SeriesId)) .ToList(); - // var reviewEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.Review)) - // .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) - // .Where(e => !errors.Contains(e.SeriesId)) - // .ToList(); + var decisions = addToWantToRead .GroupBy(item => new { item.SeriesId, item.AppUserId }) .Select(group => new @@ -645,7 +650,7 @@ public class ScrobblingService : IScrobblingService await SetAndCheckRateLimit(userRateLimits, user, license.Value); } - var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count;// + reviewEvents.Count; + var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count; _logger.LogInformation("Found {TotalEvents} Scrobble Events", totalProgress); try @@ -692,22 +697,6 @@ public class ScrobblingService : IScrobblingService Year = evt.Series.Metadata.ReleaseYear })); - // progressCounter = await ProcessEvents(reviewEvents, userRateLimits, usersToScrobble.Count, progressCounter, - // totalProgress, evt => Task.FromResult(new ScrobbleDto() - // { - // Format = evt.Format, - // AniListId = evt.AniListId, - // MALId = (int?) evt.MalId, - // ScrobbleEventType = evt.ScrobbleEventType, - // AniListToken = evt.AppUser.AniListAccessToken, - // SeriesName = evt.Series.Name, - // LocalizedSeriesName = evt.Series.LocalizedName, - // Rating = evt.Rating, - // Year = evt.Series.Metadata.ReleaseYear, - // ReviewBody = evt.ReviewBody, - // ReviewTitle = evt.ReviewTitle - // })); - progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, evt => Task.FromResult(new ScrobbleDto() { @@ -766,7 +755,22 @@ public class ScrobblingService : IScrobblingService { continue; } + + if (_tokenService.HasTokenExpired(evt.AppUser.AniListAccessToken)) + { + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() + { + Comment = "AniList token has expired and needs rotating. Scrobbles wont work until then", + Details = $"User: {evt.AppUser.UserName}", + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + await _unitOfWork.CommitAsync(); + return 0; + } + var count = await SetAndCheckRateLimit(userRateLimits, evt.AppUser, license.Value); + userRateLimits[evt.AppUserId] = count; if (count == 0) { if (usersToScrobble == 1) break; @@ -786,6 +790,14 @@ public class ScrobblingService : IScrobblingService // If a flurl exception occured, the API is likely down. Kill processing throw; } + catch (KavitaException ex) + { + if (ex.Message.Contains("Access token is invalid")) + { + _logger.LogCritical("Access Token for UserId: {UserId} needs to be rotated to continue scrobbling", evt.AppUser.Id); + return progressCounter; + } + } catch (Exception) { /* Swallow as it's already been handled in PostScrobbleUpdate */ diff --git a/API/Services/ReviewService.cs b/API/Services/ReviewService.cs index 6ad170df8..c13a93bb2 100644 --- a/API/Services/ReviewService.cs +++ b/API/Services/ReviewService.cs @@ -1,8 +1,11 @@ using System; +using System.Collections; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs.SeriesDetail; @@ -11,6 +14,7 @@ using API.Entities.Enums; using API.Helpers; using API.Helpers.Builders; using API.Services.Plus; +using EasyCaching.Core; using Flurl.Http; using HtmlAgilityPack; using Kavita.Common; @@ -48,18 +52,98 @@ public class ReviewService : IReviewService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; + private readonly ILicenseService _licenseService; + private readonly IEasyCachingProvider _cacheProvider; + public const string CacheKey = "review_"; - public ReviewService(IUnitOfWork unitOfWork, ILogger logger) + public ReviewService(IUnitOfWork unitOfWork, ILogger logger, ILicenseService licenseService, + IEasyCachingProviderFactory cachingProviderFactory) { _unitOfWork = unitOfWork; _logger = logger; + _licenseService = licenseService; FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + + _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews); } public async Task> GetReviewsForSeries(int userId, int seriesId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null) return ImmutableList.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 externalReviews; + + var result = await _cacheProvider.GetAsync>(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 SelectSpectrumOfReviews(IList reviews) + { + IList externalReviews; + var totalReviews = reviews.Count; + + if (totalReviews > 10) + { + var stepSize = Math.Max((totalReviews - 4) / 8, 1); + + var selectedReviews = new List() + { + 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> GetExternalReviews(int userId, int seriesId) { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 14f24b30e..c6dfc233e 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -19,6 +19,7 @@ public interface ITaskScheduler Task ScheduleTasks(); Task ScheduleStatsTasks(); void ScheduleUpdaterTasks(); + Task ScheduleKavitaPlusTasks(); void ScanFolder(string folderPath, TimeSpan delay); void ScanFolder(string folderPath); void ScanLibrary(int libraryId, bool force = false); @@ -72,7 +73,7 @@ public class TaskScheduler : ITaskScheduler public const string LicenseCheck = "license-check"; private static readonly ImmutableArray ScanTasks = - ImmutableArray.Create("ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"); + ["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"]; private static readonly Random Rnd = new Random(); @@ -143,11 +144,22 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions); RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions); + await ScheduleKavitaPlusTasks(); + } + + public async Task ScheduleKavitaPlusTasks() + { // KavitaPlus based (needs license check) + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + if (!await _licenseService.HasActiveSubscription(license)) + { + + return; + } RecurringJob.AddOrUpdate(CheckScrobblingTokens, () => _scrobblingService.CheckExternalAccessTokens(), Cron.Daily, RecurringJobOptions); BackgroundJob.Enqueue(() => _scrobblingService.CheckExternalAccessTokens()); // We also kick off an immediate check on startup - RecurringJob.AddOrUpdate(LicenseCheck, () => _licenseService.ValidateLicenseStatus(), LicenseService.Cron, RecurringJobOptions); - BackgroundJob.Enqueue(() => _licenseService.ValidateLicenseStatus()); + RecurringJob.AddOrUpdate(LicenseCheck, () => _licenseService.HasActiveLicense(true), LicenseService.Cron, RecurringJobOptions); + BackgroundJob.Enqueue(() => _licenseService.HasActiveLicense(true)); // KavitaPlus Scrobbling (every 4 hours) RecurringJob.AddOrUpdate(ProcessScrobblingEvents, () => _scrobblingService.ProcessUpdatesSinceLastSync(), "0 */4 * * *", RecurringJobOptions); diff --git a/UI/Web/src/app/_models/series-detail/series-detail-plus.ts b/UI/Web/src/app/_models/series-detail/series-detail-plus.ts new file mode 100644 index 000000000..679c02aee --- /dev/null +++ b/UI/Web/src/app/_models/series-detail/series-detail-plus.ts @@ -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; + ratings: Array; +} diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 15ead6554..645fd250c 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -237,6 +237,13 @@ export class ActionFactoryService { requiresAdmin: true, children: [], }, + { + action: Action.Delete, + title: 'delete', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, ], }, { diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index fa80a98c8..23f30a3d2 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -135,6 +135,26 @@ export class ActionService implements OnDestroy { }); } + async deleteLibrary(library: Partial, callback?: LibraryActionCallback) { + if (!library.hasOwnProperty('id') || library.id === undefined) { + return; + } + + if (!await this.confirmService.alert(translate('toasts.confirm-library-delete'))) { + if (callback) { + callback(library); + } + return; + } + + this.libraryService.delete(library?.id).pipe(take(1)).subscribe((res: any) => { + this.toastr.info(translate('toasts.library-deleted', {name: library.name})); + if (callback) { + callback(library); + } + }); + } + /** * Mark a series as read; updates the series pagesRead * @param series Series, must have id and name populated diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index c3a21b8b5..cedc19963 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -16,6 +16,7 @@ import {SortField} from "../_models/metadata/series-filter"; import {FilterCombination} from "../_models/metadata/v2/filter-combination"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {FilterStatement} from "../_models/metadata/v2/filter-statement"; +import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus"; @Injectable({ providedIn: 'root' @@ -25,7 +26,11 @@ export class MetadataService { baseUrl = environment.apiUrl; private validLanguages: Array = []; - constructor(private httpClient: HttpClient, private router: Router) { } + constructor(private httpClient: HttpClient) { } + + getSeriesMetadataFromPlus(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'metadata/series-detail-plus?seriesId=' + seriesId); + } getAllAgeRatings(libraries?: Array) { let method = 'metadata/age-ratings' diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index 0c52dabd9..fd3a236d8 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -1,7 +1,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; import {CommonModule} from '@angular/common'; -import {ScrobblingService} from "../../_services/scrobbling.service"; +import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event"; import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe"; @@ -11,10 +11,11 @@ import {debounceTime, take} from "rxjs/operators"; import {PaginatedResult, Pagination} from "../../_models/pagination"; import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; -import {TranslocoModule} from "@ngneat/transloco"; +import {translate, TranslocoModule} from "@ngneat/transloco"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {TranslocoLocaleModule} from "@ngneat/transloco-locale"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {ToastrService} from "ngx-toastr"; @Component({ selector: 'app-user-scrobble-history', @@ -26,9 +27,11 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; }) export class UserScrobbleHistoryComponent implements OnInit { - private readonly scrobbleService = inject(ScrobblingService); + private readonly scrobblingService = inject(ScrobblingService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); + private readonly toastr = inject(ToastrService); + protected readonly ScrobbleEventType = ScrobbleEventType; pagination: Pagination | undefined; events: Array = []; @@ -36,11 +39,16 @@ export class UserScrobbleHistoryComponent implements OnInit { 'filter': new FormControl('', []) }); - get ScrobbleEventType() { return ScrobbleEventType; } - ngOnInit() { this.loadPage({column: 'createdUtc', direction: 'desc'}); + this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => { + if (hasExpired) { + this.toastr.error(translate('toasts.anilist-token-expired')); + } + this.cdRef.markForCheck(); + }); + this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => { this.loadPage(); }) @@ -73,7 +81,7 @@ export class UserScrobbleHistoryComponent implements OnInit { const field = this.mapSortColumnField(sortEvent?.column); const query = this.formGroup.get('filter')?.value; - this.scrobbleService.getScrobbleEvents({query, field, isDescending}, page, pageSize) + this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize) .pipe(take(1)) .subscribe((result: PaginatedResult) => { this.events = result.result; diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts index 1584248db..e154b222e 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts @@ -14,8 +14,6 @@ export interface DirectoryPickerResult { folderPath: string; } - - @Component({ selector: 'app-directory-picker', templateUrl: './directory-picker.component.html', diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.ts b/UI/Web/src/app/admin/manage-library/manage-library.component.ts index bf48db286..692ab4558 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.ts +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.ts @@ -24,6 +24,7 @@ import { RouterLink } from '@angular/router'; import { NgFor, NgIf } from '@angular/common'; import {translate, TranslocoModule} from "@ngneat/transloco"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; +import {ActionService} from "../../_services/action.service"; @Component({ selector: 'app-manage-library', @@ -35,6 +36,15 @@ import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; }) export class ManageLibraryComponent implements OnInit { + private readonly actionService = inject(ActionService); + private readonly libraryService = inject(LibraryService); + private readonly modalService = inject(NgbModal); + private readonly toastr = inject(ToastrService); + private readonly confirmService = inject(ConfirmService); + private readonly hubService = inject(MessageHubService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); + libraries: Library[] = []; loading = false; /** @@ -42,11 +52,8 @@ export class ManageLibraryComponent implements OnInit { */ deletionInProgress: boolean = false; libraryTrackBy = (index: number, item: Library) => `${item.name}_${item.lastScanned}_${item.type}_${item.folders.length}`; - private readonly destroyRef = inject(DestroyRef); - constructor(private modalService: NgbModal, private libraryService: LibraryService, - private toastr: ToastrService, private confirmService: ConfirmService, - private hubService: MessageHubService, private readonly cdRef: ChangeDetectorRef) { } + ngOnInit(): void { this.getLibraries(); diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html index df44e5cc6..721c879c4 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html @@ -19,7 +19,8 @@ diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts index fbb46150b..2e72e01f1 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts @@ -9,11 +9,11 @@ import { } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; -import { take } from 'rxjs'; +import {take} from 'rxjs'; import { BulkSelectionService } from 'src/app/cards/bulk-selection.service'; import { FilterSettings } from 'src/app/metadata-filter/filter-settings'; import { ConfirmService } from 'src/app/shared/confirm.service'; -import { DownloadService } from 'src/app/shared/_services/download.service'; +import {DownloadService} from 'src/app/shared/_services/download.service'; import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service'; import { KEY_CODES } from 'src/app/shared/_services/utility.service'; import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; @@ -25,7 +25,6 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti import { ImageService } from 'src/app/_services/image.service'; import { JumpbarService } from 'src/app/_services/jumpbar.service'; import { ReaderService } from 'src/app/_services/reader.service'; -import { SeriesService } from 'src/app/_services/series.service'; import {DecimalPipe, NgIf} from '@angular/common'; import { CardItemComponent } from '../../../cards/card-item/card-item.component'; import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component'; @@ -45,11 +44,25 @@ import {Title} from "@angular/platform-browser"; }) export class BookmarksComponent implements OnInit { + private readonly translocoService = inject(TranslocoService); + private readonly readerService = inject(ReaderService); + private readonly downloadService = inject(DownloadService); + private readonly toastr = inject(ToastrService); + private readonly confirmService = inject(ConfirmService); + private readonly actionFactoryService = inject(ActionFactoryService); + private readonly router = inject(Router); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly route = inject(ActivatedRoute); + private readonly jumpbarService = inject(JumpbarService); + private readonly titleService = inject(Title); + public readonly bulkSelectionService = inject(BulkSelectionService); + public readonly imageService = inject(ImageService); + bookmarks: Array = []; series: Array = []; loadingBookmarks: boolean = false; seriesIds: {[id: number]: number} = {}; - downloadingSeries: {[id: number]: boolean} = {}; clearingSeries: {[id: number]: boolean} = {}; actions: ActionItem[] = []; jumpbarKeys: Array = []; @@ -64,16 +77,7 @@ export class BookmarksComponent implements OnInit { trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`; refresh: EventEmitter = new EventEmitter(); - private readonly translocoService = inject(TranslocoService); - - constructor(private readerService: ReaderService, - private downloadService: DownloadService, private toastr: ToastrService, - private confirmService: ConfirmService, public bulkSelectionService: BulkSelectionService, - public imageService: ImageService, private actionFactoryService: ActionFactoryService, - private router: Router, private readonly cdRef: ChangeDetectorRef, - private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute, - private jumpbarService: JumpbarService, private titleService: Title) { - + constructor() { this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { this.filter = filter; @@ -160,8 +164,11 @@ export class BookmarksComponent implements OnInit { this.readerService.getAllBookmarks(this.filter).pipe(take(1)).subscribe(bookmarks => { this.bookmarks = bookmarks; this.bookmarks.forEach(bmk => { - this.downloadingSeries[bmk.seriesId] = false; this.clearingSeries[bmk.seriesId] = false; + if (!this.seriesIds.hasOwnProperty(bmk.seriesId)) { + this.seriesIds[bmk.seriesId] = 0; + } + this.seriesIds[bmk.seriesId] += 1; }); const distinctSeriesMap = new Map(); @@ -199,14 +206,7 @@ export class BookmarksComponent implements OnInit { } downloadBookmarks(series: Series) { - this.downloadingSeries[series.id] = true; - this.cdRef.markForCheck(); - this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id), (d) => { - if (!d) { - this.downloadingSeries[series.id] = false; - this.cdRef.markForCheck(); - } - }); + this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id)); } updateFilter(data: FilterEvent) { diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html index 7852c8331..183d56e19 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html @@ -108,45 +108,52 @@
  • {{t(tabs[TabID.Files].title)}} -

    {{utilityService.formatChapterName(libraryType) + 's'}}

    + @if (!utilityService.isChapter(data)) { +

    {{utilityService.formatChapterName(libraryType) + 's'}}

    + }
    • +
      - - - - - {{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}} - - - - {{chapter.pagesRead}} / {{chapter.pages}} - {{t('unread') | uppercase}} - {{t('read') | uppercase}} - - + + + + + {{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}} + + + + {{chapter.pagesRead}} / {{chapter.pages}} + {{t('unread') | uppercase}} + {{t('read') | uppercase}} + + {{t('files')}}
        -
      • - {{file.filePath}} -
        -
        - {{t('pages')}} {{file.pages | number:''}} + @for (file of chapter.files; track file.id) { +
      • + {{file.filePath}} +
        +
        + {{t('pages')}} {{file.pages | number:''}} +
        + @if (data.hasOwnProperty('created')) { +
        + {{t('added')}} {{file.created | date: 'short' | defaultDate}} +
        + } +
        + {{t('size')}} {{file.bytes | bytes}} +
        -
        - {{t('added')}} {{file.created | date: 'short' | defaultDate}} -
        -
        - {{t('size')}} {{file.bytes | bytes}} -
        -
      -
    • + + }
  • diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts index 83e48cfc7..d4a4a85e6 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -68,12 +68,17 @@ enum TabID { }) export class CardDetailDrawerComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + + protected readonly MangaFormat = MangaFormat; + protected readonly Breakpoint = Breakpoint; + protected readonly LibraryType = LibraryType; + protected readonly TabID = TabID; + @Input() parentName = ''; @Input() seriesId: number = 0; @Input() libraryId: number = 0; @Input({required: true}) data!: Volume | Chapter; - private readonly destroyRef = inject(DestroyRef); - /** * If this is a volume, this will be first chapter for said volume. @@ -104,26 +109,13 @@ export class CardDetailDrawerComponent implements OnInit { ]; active = this.tabs[0]; - chapterMetadata!: ChapterMetadata; + chapterMetadata: ChapterMetadata | undefined; summary: string = ''; downloadInProgress: boolean = false; - get MangaFormat() { - return MangaFormat; - } - get Breakpoint() { - return Breakpoint; - } - get LibraryType() { - return LibraryType; - } - - get TabID() { - return TabID; - } constructor(public utilityService: UtilityService, public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService, @@ -160,7 +152,7 @@ export class CardDetailDrawerComponent implements OnInit { this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)) .filter(item => item.action !== Action.Edit); - this.chapterActions.push({title: 'Read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []}); + this.chapterActions.push({title: 'read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []}); if (this.isChapter) { const chapter = this.utilityService.asChapter(this.data); this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter); diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 23d043109..b38ebdf8a 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -277,26 +277,9 @@ export class CardItemComponent implements OnInit { }); this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => { - if(this.utilityService.isSeries(this.entity)) { - return events.find(e => e.entityType === 'series' && e.id == this.entity.id - && e.subTitle === this.downloadService.downloadSubtitle('series', (this.entity as Series))) || null; - } - if(this.utilityService.isVolume(this.entity)) { - return events.find(e => e.entityType === 'volume' && e.id == this.entity.id - && e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null; - } - if(this.utilityService.isChapter(this.entity)) { - return events.find(e => e.entityType === 'chapter' && e.id == this.entity.id - && e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null; - } - // Is PageBookmark[] - if(this.entity.hasOwnProperty('length')) { - return events.find(e => e.entityType === 'bookmark' - && e.subTitle === this.downloadService.downloadSubtitle('bookmark', [(this.entity as PageBookmark)])) || null; - } - return null; + console.log('Card Item download obv called for entity: ', this.entity); + return this.downloadService.mapToEntityType(events, this.entity); })); - } diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.html b/UI/Web/src/app/dashboard/_components/dashboard.component.html index a455bb5a9..9b0008c31 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.html +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.html @@ -1,86 +1,98 @@ - - -
    -
    -

    {{t('no-libraries')}} {{t('server-settings-link')}}

    -
    -
    -

    {{t('not-granted')}}

    -
    -
    -
    -
    + @if (libraries$ | async; as libraries) { + @if (libraries.length === 0) { + @if (isAdmin$ | async; as isAdmin) { +
    + @if (isAdmin) { +
    +

    {{t('no-libraries')}} {{t('server-settings-link')}}

    +
    + } @else { +
    +

    {{t('not-granted')}}

    +
    + } +
    + } + } + } - - - + @for(stream of streams; track stream.id) { + @switch (stream.streamType) { + @case (StreamType.OnDeck) { + + } + @case (StreamType.RecentlyUpdated) { + + } + @case (StreamType.NewlyAdded) { + + } + @case (StreamType.SmartFilter) { + + } + @case (StreamType.MoreInGenre) { + + } + } - - - - - - - - - + @if(stream.api | async; as data) { - + } - - + @if(stream.api | async; as data) { + - + } - - + @if(stream.api | async; as data) { + - + } - - + @if(stream.api | async; as data) { + - + } - - + @if(stream.api | async; as data) { + - + } - - + }
    + diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.ts b/UI/Web/src/app/dashboard/_components/dashboard.component.ts index c186e32f1..0475cf9fe 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; import {Title} from '@angular/platform-browser'; import {Router, RouterLink} from '@angular/router'; import {Observable, of, ReplaySubject, Subject, switchMap} from 'rxjs'; @@ -16,7 +16,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {CardItemComponent} from '../../cards/card-item/card-item.component'; import {SeriesCardComponent} from '../../cards/series-card/series-card.component'; import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component'; -import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from '@angular/common'; +import {AsyncPipe, NgForOf, NgTemplateOutlet} from '@angular/common'; import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; @@ -30,6 +30,16 @@ import {Genre} from "../../_models/metadata/genre"; import {DashboardStream} from "../../_models/dashboard/dashboard-stream"; import {StreamType} from "../../_models/dashboard/stream-type.enum"; import {LoadingComponent} from "../../shared/loading/loading.component"; +import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service"; +import {ToastrService} from "ngx-toastr"; + + +enum StreamId { + OnDeck, + RecentlyUpdatedSeries, + NewlyAddedSeries, + MoreInGenre, +} @Component({ selector: 'app-dashboard', @@ -37,8 +47,8 @@ import {LoadingComponent} from "../../shared/loading/loading.component"; styleUrls: ['./dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent, - CardItemComponent, AsyncPipe, TranslocoDirective, NgSwitchCase, NgSwitch, NgForOf, NgTemplateOutlet, LoadingComponent], + imports: [SideNavCompanionBarComponent, RouterLink, CarouselReelComponent, SeriesCardComponent, + CardItemComponent, AsyncPipe, TranslocoDirective, NgForOf, NgTemplateOutlet, LoadingComponent], }) export class DashboardComponent implements OnInit { @@ -55,6 +65,8 @@ export class DashboardComponent implements OnInit { private readonly messageHub = inject(MessageHubService); private readonly cdRef = inject(ChangeDetectorRef); private readonly dashboardService = inject(DashboardService); + private readonly scrobblingService = inject(ScrobblingService); + private readonly toastr = inject(ToastrService); libraries$: Observable = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef)) isLoadingDashboard = true; @@ -73,6 +85,7 @@ export class DashboardComponent implements OnInit { */ private loadRecentlyAdded$: ReplaySubject = new ReplaySubject(); protected readonly StreamType = StreamType; + protected readonly StreamId = StreamId; constructor() { this.loadDashboard(); @@ -105,6 +118,14 @@ export class DashboardComponent implements OnInit { } }); + this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => { + if (hasExpired) { + this.toastr.error(translate('toasts.anilist-token-expired')); + } + this.cdRef.markForCheck(); + }); + + this.isAdmin$ = this.accountService.currentUser$.pipe( takeUntilDestroyed(this.destroyRef), map(user => (user && this.accountService.hasAdminRole(user)) || false), @@ -186,18 +207,18 @@ export class DashboardComponent implements OnInit { await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded); } - handleSectionClick(sectionTitle: string) { - if (sectionTitle.toLowerCase() === 'recently updated series') { + handleSectionClick(streamId: StreamId) { + if (streamId === StreamId.RecentlyUpdatedSeries) { const params: any = {}; params['page'] = 1; - params['title'] = 'Recently Updated'; + params['title'] = translate('dashboard.recently-updated-title'); const filter = this.filterUtilityService.createSeriesV2Filter(); if (filter.sortOptions) { filter.sortOptions.sortField = SortField.LastChapterAdded; filter.sortOptions.isAscending = false; } this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe(); - } else if (sectionTitle.toLowerCase() === 'on deck') { + } else if (streamId === StreamId.OnDeck) { const params: any = {}; params['page'] = 1; params['title'] = translate('dashboard.on-deck-title'); @@ -210,7 +231,7 @@ export class DashboardComponent implements OnInit { filter.sortOptions.isAscending = false; } this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe(); - } else if (sectionTitle.toLowerCase() === 'newly added series') { + } else if (streamId === StreamId.NewlyAddedSeries) { const params: any = {}; params['page'] = 1; params['title'] = translate('dashboard.recently-added-title'); @@ -220,10 +241,10 @@ export class DashboardComponent implements OnInit { filter.sortOptions.isAscending = false; } this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe(); - } else if (sectionTitle.toLowerCase() === 'more in genre') { + } else if (streamId === StreamId.MoreInGenre) { const params: any = {}; params['page'] = 1; - params['title'] = translate('more-in-genre-title', {genre: this.genre?.title}); + params['title'] = translate('dashboard.more-in-genre-title', {genre: this.genre?.title}); const filter = this.filterUtilityService.createSeriesV2Filter(); filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains}); this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe(); diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html index 2984f521e..0d0a09b51 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html @@ -132,6 +132,9 @@ + @if(activeDownloads.length > 1) { +
  • {{activeDownloads.length}} downloads in Queue
  • + } diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts index 39f6348f9..1c6bb7acb 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts @@ -61,16 +61,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { activeEvents: number = 0; - debugMode: boolean = false; + debugMode: boolean = true; + protected readonly EVENTS = EVENTS; - get EVENTS() { - return EVENTS; - } + public readonly downloadService = inject(DownloadService); constructor(public messageHub: MessageHubService, private modalService: NgbModal, private accountService: AccountService, private confirmService: ConfirmService, - private readonly cdRef: ChangeDetectorRef, public downloadService: DownloadService) { + private readonly cdRef: ChangeDetectorRef) { } ngOnDestroy(): void { diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts index 9db60805d..0b29ff3bd 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, + Component, DestroyRef, inject, Input, OnInit, @@ -20,6 +20,7 @@ import {NgxStarsModule} from "ngx-stars"; import {ThemeService} from "../../../_services/theme.service"; import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service"; import {ImageComponent} from "../../../shared/image/image.component"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Component({ selector: 'app-external-rating', @@ -31,28 +32,31 @@ import {ImageComponent} from "../../../shared/image/image.component"; encapsulation: ViewEncapsulation.None }) export class ExternalRatingComponent implements OnInit { - @Input({required: true}) seriesId!: number; - @Input({required: true}) userRating!: number; - @Input({required: true}) hasUserRated!: boolean; - @Input({required: true}) libraryType!: LibraryType; + private readonly cdRef = inject(ChangeDetectorRef); private readonly seriesService = inject(SeriesService); private readonly accountService = inject(AccountService); private readonly themeService = inject(ThemeService); public readonly utilityService = inject(UtilityService); + public readonly destroyRef = inject(DestroyRef); + protected readonly Breakpoint = Breakpoint; + + @Input({required: true}) seriesId!: number; + @Input({required: true}) userRating!: number; + @Input({required: true}) hasUserRated!: boolean; + @Input({required: true}) libraryType!: LibraryType; + ratings: Array = []; isLoading: boolean = false; overallRating: number = -1; - starColor = this.themeService.getCssVariable('--rating-star-color'); - ngOnInit() { this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore); - this.accountService.hasValidLicense$.subscribe((res) => { + this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res) => { if (!res) return; this.isLoading = true; this.cdRef.markForCheck(); @@ -74,6 +78,4 @@ export class ExternalRatingComponent implements OnInit { this.cdRef.markForCheck(); }); } - - protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 5642eeb74..f77431d10 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -105,22 +105,32 @@
    - + } @else { + + + }
    -
    - -
    + + @if (seriesMetadata) { +
    + +
    + } +
    diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 80bb4a99e..f534f42e9 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -1,4 +1,5 @@ import { + AsyncPipe, DecimalPipe, DOCUMENT, NgClass, @@ -42,13 +43,13 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; -import {catchError, forkJoin, of} from 'rxjs'; -import {take} from 'rxjs/operators'; +import {catchError, forkJoin, Observable, of} from 'rxjs'; +import {filter, map, take} from 'rxjs/operators'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import {CardDetailDrawerComponent} from 'src/app/cards/card-detail-drawer/card-detail-drawer.component'; import {EditSeriesModalComponent} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component'; import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component'; -import {DownloadService} from 'src/app/shared/_services/download.service'; +import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service'; import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; import {Chapter} from 'src/app/_models/chapter'; import {Device} from 'src/app/_models/device/device'; @@ -105,6 +106,7 @@ import {PublicationStatus} from "../../../_models/metadata/publication-status"; import {NextExpectedChapter} from "../../../_models/series-detail/next-expected-chapter"; import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-expected-card.component"; import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe"; +import {MetadataService} from "../../../_services/metadata.service"; interface RelatedSeriesPair { series: Series; @@ -126,19 +128,22 @@ interface StoryLineItem { isChapter: boolean; } +const KavitaPlusSupportedLibraryTypes = [LibraryType.Manga, LibraryType.Book]; + @Component({ selector: 'app-series-detail', templateUrl: './series-detail.component.html', styleUrls: ['./series-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase, NextExpectedCardComponent, NgClass, NgOptimizedImage, ProviderImagePipe] + imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase, NextExpectedCardComponent, NgClass, NgOptimizedImage, ProviderImagePipe, AsyncPipe] }) export class SeriesDetailComponent implements OnInit, AfterContentChecked { private readonly destroyRef = inject(DestroyRef); private readonly route = inject(ActivatedRoute); private readonly seriesService = inject(SeriesService); + private readonly metadataService = inject(MetadataService); private readonly router = inject(Router); private readonly modalService = inject(NgbModal); private readonly toastr = inject(ToastrService); @@ -261,6 +266,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { user: User | undefined; + /** + * This is the download we get from download service. + */ + download$: Observable | null = null; + bulkActionCallback = (action: ActionItem, data: any) => { if (this.series === undefined) { return; @@ -368,6 +378,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { return; } + // Setup the download in progress + this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => { + return this.downloadService.mapToEntityType(events, this.series); + })); + this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => { if (event.event === EVENTS.SeriesRemoved) { const seriesRemovedEvent = event.payload as SeriesRemovedEvent; @@ -545,7 +560,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return; this.seriesService.getNextExpectedChapterDate(seriesId).subscribe(date => { - if (date == null || date.expectedDate === null) return; + if (date == null || date.expectedDate === null) { + if (this.nextExpectedChapter !== undefined) { + // Clear out the data so the card removes + this.nextExpectedChapter = undefined; + this.cdRef.markForCheck(); + } + return; + } this.nextExpectedChapter = date; this.cdRef.markForCheck(); @@ -563,6 +585,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { }); this.setContinuePoint(); + if (KavitaPlusSupportedLibraryTypes.includes(this.libraryType) && loadExternal) { + this.loadPlusMetadata(this.seriesId); + } + forkJoin({ libType: this.libraryService.getLibraryType(this.libraryId), series: this.seriesService.getSeries(seriesId) @@ -570,10 +596,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.libraryType = results.libType; this.series = results.series; - if (this.libraryType !== LibraryType.Comic && loadExternal) { - this.loadReviews(true); - } - this.titleService.setTitle('Kavita - ' + this.series.name + ' Details'); this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this)) @@ -670,23 +692,37 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { } } - loadRecommendations() { - this.seriesService.getRecommendationsForSeries(this.seriesId).subscribe(rec => { - rec.ownedSeries.map(r => { + // loadRecommendations() { + // this.seriesService.getRecommendationsForSeries(this.seriesId).subscribe(rec => { + // rec.ownedSeries.map(r => { + // this.seriesService.getMetadata(r.id).subscribe(m => r.summary = m.summary); + // }); + // this.combinedRecs = [...rec.ownedSeries, ...rec.externalSeries]; + // this.hasRecommendations = this.combinedRecs.length > 0; + // this.cdRef.markForCheck(); + // }); + // } + + loadPlusMetadata(seriesId: number) { + this.metadataService.getSeriesMetadataFromPlus(seriesId).subscribe(data => { + if (data === null) return; + + // Reviews + this.reviews = [...data.reviews]; + + // Recommendations + data.recommendations.ownedSeries.map(r => { this.seriesService.getMetadata(r.id).subscribe(m => r.summary = m.summary); }); - this.combinedRecs = [...rec.ownedSeries, ...rec.externalSeries]; + this.combinedRecs = [...data.recommendations.ownedSeries, ...data.recommendations.externalSeries]; this.hasRecommendations = this.combinedRecs.length > 0; + this.cdRef.markForCheck(); }); } - - loadReviews(loadRecs: boolean = false) { + loadReviews() { this.seriesService.getReviews(this.seriesId).subscribe(reviews => { this.reviews = [...reviews]; - if (loadRecs) { - this.loadRecommendations(); // We do this as first load will spam 3 calls on API layer - } this.cdRef.markForCheck(); }); } @@ -829,7 +865,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { modalRef.componentInstance.series = this.series; modalRef.closed.subscribe((closeResult: {success: boolean}) => { if (closeResult.success) { - this.loadReviews(); + this.loadReviews(); // TODO: Ensure reviews get updated here } }); } diff --git a/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.html b/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.html index 91b9672b8..4002e9125 100644 --- a/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.html @@ -4,7 +4,7 @@
    - + diff --git a/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.ts index 374c866a6..38eeef734 100644 --- a/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.ts @@ -48,6 +48,18 @@ import {ImageComponent} from "../../../shared/image/image.component"; }) export class SeriesMetadataDetailComponent implements OnChanges { + protected readonly imageService = inject(ImageService); + protected readonly utilityService = inject(UtilityService); + private readonly router = inject(Router); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly filterUtilityService = inject(FilterUtilitiesService); + + protected readonly FilterField = FilterField; + protected readonly LibraryType = LibraryType; + protected readonly MangaFormat = MangaFormat; + protected readonly TagBadgeCursor = TagBadgeCursor; + protected readonly Breakpoint = Breakpoint; + @Input({required: true}) seriesMetadata!: SeriesMetadata; @Input({required: true}) libraryType!: LibraryType; @Input() hasReadingProgress: boolean = false; @@ -60,23 +72,11 @@ export class SeriesMetadataDetailComponent implements OnChanges { isCollapsed: boolean = true; hasExtendedProperties: boolean = false; - protected readonly imageService = inject(ImageService); - protected readonly utilityService = inject(UtilityService); - private readonly router = inject(Router); - private readonly readerService = inject(ReaderService); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly filterUtilityService = inject(FilterUtilitiesService); - /** * Html representation of Series Summary */ seriesSummary: string = ''; - protected FilterField = FilterField; - protected LibraryType = LibraryType; - protected MangaFormat = MangaFormat; - protected TagBadgeCursor = TagBadgeCursor; - get WebLinks() { if (this.seriesMetadata?.webLinks === '') return []; return this.seriesMetadata?.webLinks.split(',') || []; @@ -121,6 +121,4 @@ export class SeriesMetadataDetailComponent implements OnChanges { navigate(basePage: string, id: number) { this.router.navigate([basePage, id]); } - - protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 4ab78890f..0ff8715da 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -12,7 +12,7 @@ import { tap, finalize, of, - filter, + filter, Subject, } from 'rxjs'; import { download, Download } from '../_models/download'; import { PageBookmark } from 'src/app/_models/readers/page-bookmark'; @@ -22,6 +22,10 @@ import { BytesPipe } from 'src/app/_pipes/bytes.pipe'; import {translate} from "@ngneat/transloco"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {SAVER, Saver} from "../../_providers/saver.provider"; +import {UtilityService} from "./utility.service"; +import {CollectionTag} from "../../_models/collection-tag"; +import {RecentlyAddedItem} from "../../_models/recently-added-item"; +import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; export const DEBOUNCE_TIME = 100; @@ -55,6 +59,7 @@ export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' | */ export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | undefined; +export type QueueableDownloadType = Chapter | Volume; @Injectable({ providedIn: 'root' @@ -68,14 +73,33 @@ export class DownloadService { public SIZE_WARNING = 104_857_600; private downloadsSource: BehaviorSubject = new BehaviorSubject([]); + /** + * Active Downloads + */ public activeDownloads$ = this.downloadsSource.asObservable(); + private downloadQueue: BehaviorSubject = new BehaviorSubject([]); + /** + * Queued Downloads + */ + public queuedDownloads$ = this.downloadQueue.asObservable(); + private readonly destroyRef = inject(DestroyRef); private readonly confirmService = inject(ConfirmService); private readonly accountService = inject(AccountService); private readonly httpClient = inject(HttpClient); + private readonly utilityService = inject(UtilityService); - constructor(@Inject(SAVER) private save: Saver) { } + constructor(@Inject(SAVER) private save: Saver) { + this.downloadQueue.subscribe((queue) => { + if (queue.length > 0) { + const entity = queue.shift(); + console.log('Download Queue shifting entity: ', entity); + if (entity === undefined) return; + this.processDownload(entity); + } + }); + } /** @@ -84,7 +108,7 @@ export class DownloadService { * @param downloadEntity * @returns */ - downloadSubtitle(downloadEntityType: DownloadEntityType, downloadEntity: DownloadEntity | undefined) { + downloadSubtitle(downloadEntityType: DownloadEntityType | undefined, downloadEntity: DownloadEntity | undefined) { switch (downloadEntityType) { case 'series': return (downloadEntity as Series).name; @@ -97,6 +121,7 @@ export class DownloadService { case 'logs': return ''; } + return ''; } /** @@ -117,10 +142,12 @@ export class DownloadService { case 'volume': sizeCheckCall = this.downloadVolumeSize((entity as Volume).id); downloadCall = this.downloadVolume(entity as Volume); + //this.enqueueDownload(entity as Volume); break; case 'chapter': sizeCheckCall = this.downloadChapterSize((entity as Chapter).id); downloadCall = this.downloadChapter(entity as Chapter); + //this.enqueueDownload(entity as Chapter); break; case 'bookmark': sizeCheckCall = of(0); @@ -145,8 +172,10 @@ export class DownloadService { }) ).pipe(filter(wantsToDownload => { return wantsToDownload; - }), switchMap(() => { - return downloadCall.pipe( + }), + filter(_ => downloadCall !== undefined), + switchMap(() => { + return (downloadCall || of(undefined)).pipe( tap((d) => { if (callback) callback(d); }), @@ -187,7 +216,40 @@ export class DownloadService { ); } + private getIdKey(entity: Chapter | Volume) { + if (this.utilityService.isVolume(entity)) return 'volumeId'; + if (this.utilityService.isChapter(entity)) return 'chapterId'; + if (this.utilityService.isSeries(entity)) return 'seriesId'; + return 'id'; + } + + private getDownloadEntityType(entity: Chapter | Volume): DownloadEntityType { + if (this.utilityService.isVolume(entity)) return 'volume'; + if (this.utilityService.isChapter(entity)) return 'chapter'; + if (this.utilityService.isSeries(entity)) return 'series'; + return 'logs'; // This is a hack but it will never occur + } + + private downloadEntity(entity: Chapter | Volume): Observable { + const downloadEntityType = this.getDownloadEntityType(entity); + const subtitle = this.downloadSubtitle(downloadEntityType, entity); + const idKey = this.getIdKey(entity); + const url = `${this.baseUrl}download/${downloadEntityType}?${idKey}=${entity.id}`; + + return this.httpClient.get(url, { observe: 'events', responseType: 'blob', reportProgress: true }).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, decodeURIComponent(filename)); + }), + tap((d) => this.updateDownloadState(d, downloadEntityType, subtitle, entity.id)), + finalize(() => this.finalizeDownloadState(downloadEntityType, subtitle)) + ); + } + private downloadSeries(series: Series) { + + // TODO: Call backend for all the volumes and loose leaf chapters then enqueque them all + const downloadType = 'series'; const subtitle = this.downloadSubtitle(downloadType, series); return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id, @@ -227,38 +289,42 @@ export class DownloadService { } private downloadChapter(chapter: Chapter) { - const downloadType = 'chapter'; - const subtitle = this.downloadSubtitle(downloadType, chapter); - return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id, - {observe: 'events', responseType: 'blob', reportProgress: true} - ).pipe( - throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), - download((blob, filename) => { - this.save(blob, decodeURIComponent(filename)); - }), - tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)), - finalize(() => this.finalizeDownloadState(downloadType, subtitle)) - ); + return this.downloadEntity(chapter); + + // const downloadType = 'chapter'; + // const subtitle = this.downloadSubtitle(downloadType, chapter); + // return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id, + // {observe: 'events', responseType: 'blob', reportProgress: true} + // ).pipe( + // throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + // download((blob, filename) => { + // this.save(blob, decodeURIComponent(filename)); + // }), + // tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)), + // finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + // ); } - private downloadVolume(volume: Volume): Observable { - const downloadType = 'volume'; - const subtitle = this.downloadSubtitle(downloadType, volume); - return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id, - {observe: 'events', responseType: 'blob', reportProgress: true} - ).pipe( - throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), - download((blob, filename) => { - this.save(blob, decodeURIComponent(filename)); - }), - tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)), - finalize(() => this.finalizeDownloadState(downloadType, subtitle)) - ); + private downloadVolume(volume: Volume) { + return this.downloadEntity(volume); + // const downloadType = 'volume'; + // const subtitle = this.downloadSubtitle(downloadType, volume); + // return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id, + // {observe: 'events', responseType: 'blob', reportProgress: true} + // ).pipe( + // throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + // download((blob, filename) => { + // this.save(blob, decodeURIComponent(filename)); + // }), + // tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)), + // finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + // ); } private async confirmSize(size: number, entityType: DownloadEntityType) { return (size < this.SIZE_WARNING || - await this.confirmService.confirm(translate('toasts.confirm-download-size', {entityType: translate('entity-type.' + entityType), size: bytesPipe.transform(size)}))); + await this.confirmService.confirm(translate('toasts.confirm-download-size', + {entityType: translate('entity-type.' + entityType), size: bytesPipe.transform(size)}))); } private downloadBookmarks(bookmarks: PageBookmark[]) { @@ -276,4 +342,60 @@ export class DownloadService { finalize(() => this.finalizeDownloadState(downloadType, subtitle)) ); } + + + + private processDownload(entity: QueueableDownloadType): void { + const downloadObservable = this.downloadEntity(entity); + console.log('Process Download called for entity: ', entity); + + // When we consume one, we need to take it off the queue + + downloadObservable.subscribe((downloadEvent) => { + // Download completed, process the next item in the queue + if (downloadEvent.state === 'DONE') { + this.processNextDownload(); + } + }); + } + + private processNextDownload(): void { + const currentQueue = this.downloadQueue.value; + if (currentQueue.length > 0) { + const nextEntity = currentQueue[0]; + this.processDownload(nextEntity); + } + } + + private enqueueDownload(entity: QueueableDownloadType): void { + const currentQueue = this.downloadQueue.value; + const newQueue = [...currentQueue, entity]; + this.downloadQueue.next(newQueue); + + // If the queue was empty, start processing the download + if (currentQueue.length === 0) { + this.processNextDownload(); + } + } + + mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter) { + if(this.utilityService.isSeries(entity)) { + return events.find(e => e.entityType === 'series' && e.id == entity.id + && e.subTitle === this.downloadSubtitle('series', (entity as Series))) || null; + } + if(this.utilityService.isVolume(entity)) { + return events.find(e => e.entityType === 'volume' && e.id == entity.id + && e.subTitle === this.downloadSubtitle('volume', (entity as Volume))) || null; + } + if(this.utilityService.isChapter(entity)) { + return events.find(e => e.entityType === 'chapter' && e.id == entity.id + && e.subTitle === this.downloadSubtitle('chapter', (entity as Chapter))) || null; + } + // Is PageBookmark[] + if(entity.hasOwnProperty('length')) { + return events.find(e => e.entityType === 'bookmark' + && e.subTitle === this.downloadSubtitle('bookmark', [(entity as PageBookmark)])) || null; + } + return null; + } } diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index 8e4de95b0..717ed2480 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -157,6 +157,9 @@ export class SideNavComponent implements OnInit { case (Action.AnalyzeFiles): await this.actionService.analyzeFiles(library); break; + case (Action.Delete): + await this.actionService.deleteLibrary(library); + break; case (Action.Edit): this.actionService.editLibrary(library, () => window.scrollTo(0, 0)); break; diff --git a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.html b/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.html index 31aa42cd0..92c89f73f 100644 --- a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.html +++ b/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.html @@ -3,7 +3,18 @@
    -

    {{t('title')}}

    +
    +

    {{t('title')}} + @if(!tokenExpired) { + + {{t('token-valid')}} + } @else { + + {{t('token-not-valid')}} + } +

    + +
    diff --git a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.scss b/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.scss index 5953c0d3f..bc6ccbdf7 100644 --- a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.scss +++ b/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.scss @@ -1,3 +1,9 @@ .error { color: var(--error-color); } + +.confirm-icon { + color: var(--primary-color); + font-size: 14px; + vertical-align: middle; +} diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.html b/UI/Web/src/app/user-settings/change-email/change-email.component.html index 84b7798f2..823a9557a 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.html +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.html @@ -5,10 +5,13 @@

    {{t('email-label')}} - - + @if(emailConfirmed) { + + {{t('email-confirmed')}} + } @else { + {{t('email-not-confirmed')}} - + }

    diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.scss b/UI/Web/src/app/user-settings/change-email/change-email.component.scss index e438fde43..89f298e2e 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.scss +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.scss @@ -3,3 +3,7 @@ font-size: 14px; vertical-align: middle; } + +.error { + color: var(--error-color); +} diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index f45e07a51..bc952e62d 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -245,6 +245,7 @@ "email-label": "{{common.email}}", "current-password-label": "Current Password", "email-not-confirmed": "This email is not confirmed", + "email-confirmed": "This email is confirmed", "email-updated-title": "Email Updated", "email-updated-description": "You can use the following link below to confirm the email for your account. If your server is externally accessible, an email will have been sent to the email and the link can be used to confirm the email.", "setup-user-account": "Setup user's account", @@ -722,6 +723,7 @@ "series-metadata-detail": { "links-title": "Links", + "rating-title": "Ratings", "genres-title": "Genres", "tags-title": "Tags", "collections-title": "{{side-nav.collections}}", @@ -2000,7 +2002,8 @@ "confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?", "smart-filter-deleted": "Smart Filter Deleted", "smart-filter-updated": "Created/Updated smart filter", - "external-source-already-exists": "An External Source already exists with the same Name/Host/API Key" + "external-source-already-exists": "An External Source already exists with the same Name/Host/API Key", + "anilist-token-expired": "Your AniList token is expired. Scrobbling will no longer process until you re-generate it in User Settings > Account" }, "actionable": { diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index 027a9d686..f688e36de 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -13,6 +13,9 @@ --primary-color-scrollbar: rgba(74,198,148,0.75); --text-muted-color: lightgrey; + /* New Color scheme */ + --secondary-color: #212328; + /* Meta and Globals */ --theme-color: #000000; --color-scheme: dark; diff --git a/openapi.json b/openapi.json index e783ac7fd..d67309f6f 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.12.2" + "version": "0.7.12.3" }, "servers": [ { @@ -3538,6 +3538,7 @@ "tags": [ "Metadata" ], + "summary": "Returns all languages Kavita can accept", "responses": { "200": { "description": "Success", @@ -3612,6 +3613,47 @@ } } }, + "/api/Metadata/series-detail-plus": { + "get": { + "tags": [ + "Metadata" + ], + "summary": "Fetches the details needed from Kavita+ for Series Detail page", + "parameters": [ + { + "name": "seriesId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/SeriesDetailPlusDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesDetailPlusDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SeriesDetailPlusDto" + } + } + } + } + } + } + }, "/api/Opds/{apiKey}": { "post": { "tags": [ @@ -17652,6 +17694,30 @@ "additionalProperties": false, "description": "This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout.\r\nThis is subject to change, do not rely on this Data model." }, + "SeriesDetailPlusDto": { + "type": "object", + "properties": { + "recommendations": { + "$ref": "#/components/schemas/RecommendationDto" + }, + "reviews": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserReviewDto" + }, + "nullable": true + }, + "ratings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RatingDto" + }, + "nullable": true + } + }, + "additionalProperties": false, + "description": "All the data from Kavita+ for Series Detail" + }, "SeriesDto": { "type": "object", "properties": {