using System; using System.Collections.Generic; using System.Threading.Tasks; using EasyCaching.Core; using Hangfire; using Kavita.API.Database; using Kavita.API.Repositories; using Kavita.API.Services; using Kavita.API.Services.Plus; using Kavita.Common; using Kavita.Common.Extensions; using Kavita.Common.Helpers; using Kavita.Models.Constants; using Kavita.Models.DTOs; using Kavita.Models.DTOs.Dashboard; using Kavita.Models.DTOs.Filtering.v2; using Kavita.Models.DTOs.Metadata.Matching; using Kavita.Models.DTOs.Recommendation; using Kavita.Models.DTOs.SeriesDetail; using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.MetadataMatching; using Kavita.Server.Attributes; using Kavita.Server.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Kavita.Server.Controllers; public class SeriesController( ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService, ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, IExternalMetadataService externalMetadataService, IHostEnvironment environment) : BaseApiController { private readonly IEasyCachingProvider _externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries); private readonly IEasyCachingProvider _matchSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusMatchSeries); private const string CacheKey = "externalSeriesData_"; private const string MatchSeriesCacheKey = "matchSeries_"; /// /// Gets series with the applied Filter /// /// /// /// [HttpPost("v2")] public async Task>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto) { var userId = UserId; var series = await unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto); 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 [SeriesAccess] [HttpGet("{seriesId:int}")] public async Task> GetSeries(int seriesId) { var series = await unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, UserId); if (series == null) return NoContent(); return Ok(series); } /// /// Deletes a series from Kavita /// /// /// If the series was deleted or not [HttpDelete("{seriesId}")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task> DeleteSeries(int seriesId) { var username = Username!; logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username); return Ok(await seriesService.DeleteMultipleSeries([seriesId])); } /// /// Deletes multiple series from Kavita at once /// /// /// [HttpPost("delete-multiple")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task DeleteMultipleSeries(DeleteSeriesDto dto) { var username = Username!; logger.LogInformation("Series {@SeriesId} is being deleted by {UserName}", dto.SeriesIds, username); if (await seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok(true); return BadRequest(await localizationService.Translate(UserId, "generic-series-delete")); } /// /// Returns All volumes for a series with progress information and Chapters /// /// /// [SeriesAccess] [HttpGet("volumes")] public async Task>> GetVolumes(int seriesId) { return Ok(await unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, UserId)); } /// /// Returns a single Volume with progress information and Chapters /// /// /// [VolumeAccess] [HttpGet("volume")] public async Task> GetVolume(int volumeId) { var vol = await unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, UserId); if (vol == null) return NoContent(); return Ok(vol); } /// /// Returns a single Chapter with progress information /// /// /// [ChapterAccess] [HttpGet("chapter")] public async Task> GetChapter(int chapterId) { var chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, UserId); if (chapter == null) return NoContent(); return Ok(chapter); } /// /// Updates the Series /// /// /// Updated Series [HttpPost("update")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task> UpdateSeries(UpdateSeriesDto updateSeries) { var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(updateSeries.Id); if (series == null) return BadRequest(await localizationService.Translate(UserId, "series-doesnt-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 = null; series.CoverImageLocked = false; series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Covers); logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); series.ResetColorScape(); } unitOfWork.SeriesRepository.Update(series); if (!await unitOfWork.CommitAsync()) { return BadRequest(await localizationService.Translate(UserId, "generic-series-update")); } if (needsRefreshMetadata) { await taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id); } return Ok(await unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(series.Id, UserId)); } /// /// Gets all recently added series /// /// /// /// [HttpPost("recently-added-v2")] public async Task>> GetRecentlyAddedV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams) { var userId = UserId; var series = await unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, userParams, filterDto); 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 /// /// Page size and offset /// [HttpPost("recently-updated-series")] public async Task>> GetRecentlyAddedChapters([FromQuery] UserParams? userParams) { userParams ??= UserParams.Default; return Ok(await unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(UserId, userParams)); } /// /// Returns all series for the library /// /// /// /// Optional user id to request the OnDeck for someone else. They must have profile sharing enabled when doing so /// This is not in use /// /// [HttpPost("all-v2")] [ProfilePrivacy(allowMissingUserId: true)] public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int? userId = null, [FromQuery] int libraryId = 0, [FromQuery] QueryContext context = QueryContext.None) { var seriesForUser = userId ?? UserId; filterDto.Statements.AddRange(await seriesService.GetProfilePrivacyStatements(seriesForUser, UserId)); var series = await unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(seriesForUser, userParams, filterDto, context); 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 /// [HttpPost("on-deck")] public async Task>> GetOnDeck([FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var pagedList = await unitOfWork.SeriesRepository.GetOnDeck(UserId, libraryId, userParams, null); Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); return Ok(pagedList); } /// /// Removes a series from displaying on deck until the next read event on that series /// /// /// [HttpPost("remove-from-on-deck")] public async Task RemoveFromOnDeck([FromQuery] int seriesId) { await unitOfWork.SeriesRepository.RemoveFromOnDeck(seriesId, UserId); return Ok(); } /// /// Get series a user is currently reading, requires the user to share their profile /// /// /// /// [ProfilePrivacy] [HttpGet("currently-reading")] public async Task>> GetCurrentlyReadingForUser([FromQuery] UserParams userParams, [FromQuery] int userId) { var pagedList = await seriesService.GetCurrentlyReading(userId, UserId, userParams); Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); return Ok(pagedList); } /// /// Runs a Cover Image Generation task /// /// /// [HttpPost("refresh-metadata")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto) { await taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate, refreshSeriesDto.ForceColorscape); return Ok(); } /// /// Scan a series and force each file to be updated. This should be invoked via the User, hence why we force. /// /// /// [HttpPost("scan")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public ActionResult ScanSeries(RefreshSeriesDto refreshSeriesDto) { taskScheduler.ScanSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, true); return Ok(); } /// /// Run a file analysis on the series. /// /// /// [HttpPost("analyze")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public ActionResult AnalyzeSeries(RefreshSeriesDto refreshSeriesDto) { taskScheduler.AnalyzeFilesForSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); return Ok(); } /// /// Returns metadata for a given series /// /// /// [SeriesAccess] [HttpGet("metadata")] public async Task> GetSeriesMetadata(int seriesId) { return Ok(await unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId)); } /// /// Update series metadata /// /// /// [HttpPost("metadata")] [Authorize(PolicyGroups.AdminPolicy)] public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) { if (!await seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto)) return BadRequest(await localizationService.Translate(UserId, "update-metadata-fail")); return Ok(await localizationService.Translate(UserId, "series-updated")); } /// /// 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 = UserId; var series = await unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, userParams); 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(await localizationService.Translate(UserId, "invalid-payload")); return Ok(await unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, UserId)); } /// /// Get the age rating for the enum value /// /// /// [HttpGet("age-rating")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["ageRating"])] public async Task> GetAgeRating(int ageRating) { var val = (AgeRating) ageRating; if (val == AgeRating.NotApplicable) return await localizationService.Translate(UserId, "age-restriction-not-applicable"); return Ok(val.ToDescription()); } /// /// Get a special DTO for Series Detail page. /// /// /// /// Do not rely on this API externally. May change without hesitation. [SeriesAccess] [HttpGet("series-detail")] public async Task> GetSeriesDetailBreakdown(int seriesId) { try { return await seriesService.GetSeriesDetail(seriesId, UserId); } catch (KavitaException ex) { return BadRequest(await localizationService.Translate(UserId, ex.Message)); } } /// /// Fetches the related series for a given series /// /// /// Type of Relationship to pull back /// [SeriesAccess] [HttpGet("related")] public async Task>> GetRelatedSeries(int seriesId, RelationKind relation) { return Ok(await unitOfWork.SeriesRepository.GetSeriesForRelationKind(UserId, seriesId, relation)); } /// /// Returns all related series against the passed series Id /// /// /// [SeriesAccess] [HttpGet("all-related")] public async Task> GetAllRelatedSeries(int seriesId) { return Ok(await seriesService.GetRelatedSeries(UserId, seriesId)); } /// /// Update the relations attached to the Series. Does not generate associated Sequel/Prequel pairs on target series. /// /// /// [HttpPost("update-related")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto) { if (await seriesService.UpdateRelatedSeries(dto)) { return Ok(); } return BadRequest(await localizationService.Translate(UserId, "generic-relationship")); } [KPlus] [HttpGet("external-series-detail")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task> GetExternalSeriesInfo(int? aniListId, long? malId, int? seriesId) { var cacheKey = $"{CacheKey}-{aniListId ?? 0}-{malId ?? 0}-{seriesId ?? 0}"; var results = await _externalSeriesCacheProvider.GetAsync(cacheKey); if (results.HasValue) { return Ok(results.Value); } try { var ret = await externalMetadataService.GetExternalSeriesDetail(aniListId, malId, seriesId); await _externalSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(15)); return Ok(ret); } catch (Exception) { return BadRequest("Unable to load External Series details"); } } /// /// Based on the delta times between when chapters are added, for series that are not Completed/Cancelled/Hiatus, forecast the next /// date when it will be available. /// /// /// [SeriesAccess] [HttpGet("next-expected")] public async Task> GetNextExpectedChapter(int seriesId) { var userId = UserId; return Ok(await seriesService.GetEstimatedChapterCreationDate(seriesId, userId)); } /// /// Sends a request to Kavita+ API for all potential matches, sorted by relevance /// /// /// [HttpPost("match")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task>> MatchSeries(MatchSeriesDto dto) { var cacheKey = $"{MatchSeriesCacheKey}-{dto.SeriesId}-{dto.Query}"; var results = await _matchSeriesCacheProvider.GetAsync>(cacheKey); if (results.HasValue && !environment.IsDevelopment()) { return Ok(results.Value); } var ret = await externalMetadataService.MatchSeries(dto); await _matchSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(1)); return Ok(ret); } /// /// This will perform the fix match /// /// /// /// [HttpPost("update-match")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int? aniListId, [FromQuery] long? malId, [FromQuery] int? cbrId) { BackgroundJob.Enqueue(() => externalMetadataService.FixSeriesMatch(seriesId, aniListId, malId, cbrId)); return Ok(); } /// /// When true, will not perform a match and will prevent Kavita from attempting to match/scrobble against this series /// /// /// /// [HttpPost("dont-match")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task UpdateDontMatch([FromQuery] int seriesId, [FromQuery] bool dontMatch) { await externalMetadataService.UpdateSeriesDontMatch(seriesId, dontMatch); return Ok(); } /// /// Returns all Series that a user has access to /// /// [HttpGet("series-with-annotations")] public async Task>> GetSeriesWithAnnotations() { var data = await unitOfWork.AnnotationRepository.GetSeriesWithAnnotations(UserId); return Ok(data); } }