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.Repositories; using API.DTOs.Filtering; using API.DTOs.Metadata; using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; using API.Services.Plus; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; #nullable enable public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IExternalMetadataService metadataService) : BaseApiController { public const string CacheKey = "kavitaPlusSeriesDetail_"; /// /// Fetches genres from the instance /// /// String separated libraryIds or null for all genres /// Context from which this API was invoked /// [HttpGet("genres")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds", "context"])] public async Task>> GetAllGenres(string? libraryIds, QueryContext context = QueryContext.None) { var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) .Select(int.Parse) .ToList(); return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context)); } /// /// 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 is {Count: > 0}) { return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids)); } return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(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 is {Count: > 0}) { return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId(), ids)); } return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(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 is {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 = ["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 = ["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)); } /// /// Given a language code returns the display name /// /// /// [HttpGet("language-title")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["code"])] public ActionResult GetLanguageTitle(string code) { if (string.IsNullOrEmpty(code)) return BadRequest("Code must be provided"); return CultureInfo.GetCultures(CultureTypes.AllCultures) .Where(l => code.Equals(l.IetfLanguageTag)) .Select(c => c.DisplayName) .FirstOrDefault(); } /// /// If this Series is on Kavita+ Blacklist, removes it. If already cached, invalidates it. /// This then attempts to refresh data from Kavita+ for this series. /// /// /// // [HttpPost("force-refresh")] // public async Task ForceRefresh(int seriesId) // { // await metadataService.ForceKavitaPlusRefresh(seriesId); // return Ok(); // } /// /// 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 /// Series Id /// Library Type /// [HttpGet("series-detail-plus")] public async Task> GetKavitaPlusSeriesDetailData(int seriesId, LibraryType libraryType) { var userReviews = (await unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, User.GetUserId())) .Where(r => !string.IsNullOrEmpty(r.Body)) .OrderByDescending(review => review.Username.Equals(User.GetUsername()) ? 1 : 0) .ToList(); var ret = await metadataService.GetSeriesDetailPlus(seriesId, libraryType); await PrepareSeriesDetail(userReviews, ret); return Ok(ret); } private async Task PrepareSeriesDetail(List userReviews, SeriesDetailPlusDto? ret) { var isAdmin = User.IsInRole(PolicyConstants.AdminRole); var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!; userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(ret.Reviews.ToList())); ret.Reviews = userReviews; if (!isAdmin && ret.Recommendations != null && user != null) { // 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 = []; } if (ret.Recommendations != null && user != null) { ret.Recommendations.OwnedSeries ??= []; await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries); } } }