using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Misc; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services; using API.Services.Plus; using EasyCaching.Core; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; #nullable enable public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILicenseService licenseService, IExternalMetadataService metadataService, IEasyCachingProviderFactory cachingProviderFactory) : BaseApiController { private readonly IEasyCachingProvider _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusSeriesDetail); public const string CacheKey = "kavitaPlusSeriesDetail_"; /// /// Fetches genres from the instance /// /// String separated libraryIds or null for all genres /// [HttpGet("genres")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})] public async Task>> GetAllGenres(string? libraryIds) { 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.GetAllGenreDtosAsync(User.GetUserId())); } /// /// Fetches people from the instance by role /// /// role /// [HttpGet("people-by-role")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["role"])] 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())); } /// /// Fetches people from the instance /// /// String separated libraryIds or null for all people /// [HttpGet("people")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds"])] public async Task>> GetAllPeople(string? libraryIds) { 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.GetAllPersonDtosAsync(User.GetUserId())); } /// /// Fetches all tags from the instance /// /// String separated libraryIds or null for all tags /// [HttpGet("tags")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds"])] public async Task>> GetAllTags(string? libraryIds) { 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.GetAllTagDtosAsync(User.GetUserId())); } /// /// Fetches all age ratings from the instance /// /// String separated libraryIds or null for all ratings /// This API is cached for 1 hour, varying by libraryIds /// [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])] [HttpGet("age-ratings")] public async Task>> GetAllAgeRatings(string? libraryIds) { 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(Enum.GetValues().Select(t => new AgeRatingDto() { Title = t.ToDescription(), Value = t }).Where(r => r.Value > AgeRating.NotApplicable)); } /// /// Fetches all publication status' from the instance /// /// String separated libraryIds or null for all publication status /// This API is cached for 1 hour, varying by libraryIds /// [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})] [HttpGet("publication-status")] public ActionResult> GetAllPublicationStatus(string? libraryIds) { 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(Enum.GetValues().Select(t => new PublicationStatusDto() { Title = t.ToDescription(), Value = t }).OrderBy(t => t.Title)); } /// /// Fetches all age languages from the libraries passed (or if none passed, all in the server) /// /// This does not perform RBS for the user if they have Library access due to the non-sensitive nature of languages /// String separated libraryIds or null for all ratings /// [HttpGet("languages")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new []{"libraryIds"})] 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)); } /// /// Returns all languages Kavita can accept /// /// [HttpGet("all-languages")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] public IEnumerable GetAllValidLanguages() { return CultureInfo.GetCultures(CultureTypes.AllCultures).Select(c => new LanguageDto() { Title = c.DisplayName, IsoCode = c.IetfLanguageTag }).Where(l => !string.IsNullOrEmpty(l.IsoCode)); } /// /// Returns summary for the chapter /// /// /// [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")); return Ok(chapter.Summary); } /// /// Fetches the details needed from Kavita+ for Series Detail page /// /// This will hit upstream K+ if the data in local db is 2 weeks old /// /// [HttpGet("series-detail-plus")] public async Task> GetKavitaPlusSeriesDetailData(int seriesId) { if (!await licenseService.HasActiveLicense()) { return Ok(null); } var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId()); if (user == null) return Unauthorized(); var userReviews = (await unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, user.Id)) .Where(r => !string.IsNullOrEmpty(r.BodyJustText)) .OrderByDescending(review => review.Username.Equals(user.UserName) ? 1 : 0) .ToList(); var cacheKey = CacheKey + seriesId; var results = await _cacheProvider.GetAsync(cacheKey); if (results.HasValue) { var cachedResult = results.Value; await PrepareSeriesDetail(userReviews, cachedResult, user); return cachedResult; } var ret = await metadataService.GetSeriesDetail(user.Id, seriesId); if (ret == null) return Ok(new SeriesDetailPlusDto() { Reviews = userReviews, Recommendations = null, Ratings = null }); await _cacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromHours(48)); // For some reason if we don't use a different instance, the cache keeps changes made below var newCacheResult = (await _cacheProvider.GetAsync(cacheKey)).Value; await PrepareSeriesDetail(userReviews, newCacheResult, user); return Ok(newCacheResult); } private async Task PrepareSeriesDetail(List userReviews, SeriesDetailPlusDto ret, AppUser user) { var isAdmin = await unitOfWork.UserRepository.IsUserAdminAsync(user); userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList())); ret.Reviews = userReviews; if (!isAdmin) { // Re-obtain owned series and take into account age restriction ret.Recommendations.OwnedSeries = await unitOfWork.SeriesRepository.GetSeriesDtoByIdsAsync( ret.Recommendations.OwnedSeries.Select(s => s.Id), user); ret.Recommendations.ExternalSeries = new List(); } } }