using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; using API.Services.Plus; using EasyCaching.Core; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace API.Controllers; public class SeriesController : BaseApiController { private readonly ILogger _logger; private readonly ITaskScheduler _taskScheduler; private readonly IUnitOfWork _unitOfWork; private readonly ISeriesService _seriesService; private readonly ILicenseService _licenseService; private readonly IEasyCachingProvider _ratingCacheProvider; private readonly IEasyCachingProvider _reviewCacheProvider; private readonly IEasyCachingProvider _recommendationCacheProvider; public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService, ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory) { _logger = logger; _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; _seriesService = seriesService; _licenseService = licenseService; _ratingCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings); _reviewCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews); _recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations); } [HttpPost] public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); // Apply progress/rating information (I can't work out how to do this in initial query) if (series == null) return BadRequest("Could not get series for library"); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); } /// /// Fetches a Series for a given Id /// /// Series Id to fetch details for /// /// Throws an exception if the series Id does exist [HttpGet("{seriesId:int}")] public async Task> GetSeries(int seriesId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); if (series == null) return NoContent(); return Ok(series); } [Authorize(Policy = "RequireAdminRole")] [HttpDelete("{seriesId}")] public async Task> DeleteSeries(int seriesId) { var username = User.GetUsername(); _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username); return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId})); } [Authorize(Policy = "RequireAdminRole")] [HttpPost("delete-multiple")] public async Task DeleteMultipleSeries(DeleteSeriesDto dto) { var username = User.GetUsername(); _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", dto.SeriesIds, username); if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok(); return BadRequest("There was an issue deleting the series requested"); } /// /// Returns All volumes for a series with progress information and Chapters /// /// /// [HttpGet("volumes")] public async Task>> GetVolumes(int seriesId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)); } [HttpGet("volume")] public async Task> GetVolume(int volumeId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var vol = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId); if (vol == null) return NoContent(); return Ok(vol); } [HttpGet("chapter")] public async Task> GetChapter(int chapterId) { var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); if (chapter == null) return NoContent(); return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter)); } [HttpGet("chapter-metadata")] public async Task> GetChapterMetadata(int chapterId) { return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId)); } /// /// Update the user rating for the given series /// /// /// [HttpPost("update-rating")] public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings); if (!await _seriesService.UpdateRating(user!, updateSeriesRatingDto)) return BadRequest("There was a critical error."); return Ok(); } /// /// Updates the Series /// /// /// [HttpPost("update")] public async Task UpdateSeries(UpdateSeriesDto updateSeries) { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(updateSeries.Id); if (series == null) return BadRequest("Series does not exist"); series.NormalizedName = series.Name.ToNormalized(); if (!string.IsNullOrEmpty(updateSeries.SortName?.Trim())) { series.SortName = updateSeries.SortName.Trim(); } series.LocalizedName = updateSeries.LocalizedName?.Trim(); series.NormalizedLocalizedName = series.LocalizedName?.ToNormalized(); series.SortNameLocked = updateSeries.SortNameLocked; series.LocalizedNameLocked = updateSeries.LocalizedNameLocked; var needsRefreshMetadata = false; // This is when you hit Reset if (series.CoverImageLocked && !updateSeries.CoverImageLocked) { // Trigger a refresh when we are moving from a locked image to a non-locked needsRefreshMetadata = true; series.CoverImage = string.Empty; series.CoverImageLocked = updateSeries.CoverImageLocked; } _unitOfWork.SeriesRepository.Update(series); if (await _unitOfWork.CommitAsync()) { if (needsRefreshMetadata) { _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id); } return Ok(); } return BadRequest("There was an error with updating the series"); } /// /// Gets all recently added series /// /// /// /// /// [ResponseCache(CacheProfileName = "Instant")] [HttpPost("recently-added")] public async Task>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var series = await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto); // Apply progress/rating information (I can't work out how to do this in initial query) if (series == null) return BadRequest("Could not get series"); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); } /// /// Returns series that were recently updated, like adding or removing a chapter /// /// [ResponseCache(CacheProfileName = "Instant")] [HttpPost("recently-updated-series")] public async Task>> GetRecentlyAddedChapters() { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, 20)); } /// /// Returns all series for the library /// /// /// /// /// [HttpPost("all")] public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); // Apply progress/rating information (I can't work out how to do this in initial query) if (series == null) return BadRequest("Could not get series"); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); } /// /// Fetches series that are on deck aka have progress on them. /// /// /// /// Default of 0 meaning all libraries /// [ResponseCache(CacheProfileName = "Instant")] [HttpPost("on-deck")] public async Task>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList); Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); return Ok(pagedList); } /// /// Runs a Cover Image Generation task /// /// /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("refresh-metadata")] public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto) { _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); return Ok(); } /// /// Scan a series and force each file to be updated. This should be invoked via the User, hence why we force. /// /// /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("scan")] public ActionResult ScanSeries(RefreshSeriesDto refreshSeriesDto) { _taskScheduler.ScanSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); return Ok(); } /// /// Run a file analysis on the series. /// /// /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("analyze")] public ActionResult AnalyzeSeries(RefreshSeriesDto refreshSeriesDto) { _taskScheduler.AnalyzeFilesForSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); return Ok(); } /// /// Returns metadata for a given series /// /// /// [HttpGet("metadata")] public async Task> GetSeriesMetadata(int seriesId) { var metadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId); return Ok(metadata); } /// /// Update series metadata /// /// /// [HttpPost("metadata")] public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) { if (await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto)) { if (await _licenseService.HasActiveLicense()) { _logger.LogDebug("Clearing cache as series weblinks may have changed"); await _reviewCacheProvider.RemoveAsync(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId); await _ratingCacheProvider.RemoveAsync(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId); var allUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(s => s.Id); foreach (var userId in allUsers) { await _recommendationCacheProvider.RemoveAsync(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}"); } } return Ok("Successfully updated"); } return BadRequest("Could not update metadata"); } /// /// Returns all Series grouped by the passed Collection Id with Pagination. /// /// Collection Id to pull series from /// Pagination information /// [HttpGet("series-by-collection")] public async Task>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, userParams); // Apply progress/rating information (I can't work out how to do this in initial query) if (series == null) return BadRequest("Could not get series for collection"); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); } /// /// Fetches Series for a set of Ids. This will check User for permission access and filter out any Ids that don't exist or /// the user does not have access to. /// /// [HttpPost("series-by-ids")] public async Task>> GetAllSeriesById(SeriesByIdsDto dto) { if (dto.SeriesIds == null) return BadRequest("Must pass seriesIds"); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId)); } /// /// Get the age rating for the enum value /// /// /// /// This is cached for an hour [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})] [HttpGet("age-rating")] public ActionResult GetAgeRating(int ageRating) { var val = (AgeRating) ageRating; if (val == AgeRating.NotApplicable) return "No Restriction"; return Ok(val.ToDescription()); } /// /// Get a special DTO for Series Detail page. /// /// /// /// Do not rely on this API externally. May change without hesitation. [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"seriesId"})] [HttpGet("series-detail")] public async Task> GetSeriesDetailBreakdown(int seriesId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return await _seriesService.GetSeriesDetail(seriesId, userId); } /// /// Fetches the related series for a given series /// /// /// Type of Relationship to pull back /// [HttpGet("related")] public async Task>> GetRelatedSeries(int seriesId, RelationKind relation) { // Send back a custom DTO with each type or maybe sorted in some way var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation)); } /// /// Returns all related series against the passed series Id /// /// /// [HttpGet("all-related")] public async Task> GetAllRelatedSeries(int seriesId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _seriesService.GetRelatedSeries(userId, seriesId)); } /// /// Update the relations attached to the Series. Does not generate associated Sequel/Prequel pairs on target series. /// /// /// [Authorize(Policy="RequireAdminRole")] [HttpPost("update-related")] public async Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto) { if (await _seriesService.UpdateRelatedSeries(dto)) { return Ok(); } return BadRequest("There was an issue updating relationships"); } }