using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Kavita.API.Attributes; using Kavita.API.Database; using Kavita.API.Repositories; using Kavita.API.Services; using Kavita.API.Services.SignalR; using Kavita.Common.Extensions; using Kavita.Models.Constants; using Kavita.Models.DTOs; using Kavita.Models.DTOs.SignalR; using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.MetadataMatching; using Kavita.Server.Attributes; using Kavita.Services.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Nager.ArticleNumber; namespace Kavita.Server.Controllers; public class ChapterController( IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger logger) : BaseApiController { /// /// Gets a single chapter /// /// /// [HttpGet] [ChapterAccess] public async Task> GetChapter(int chapterId) { var chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, UserId); return Ok(chapter); } /// /// Removes a Chapter /// /// /// [HttpDelete] [Authorize(Policy = PolicyGroups.AdminPolicy)] [DisallowRole(PolicyConstants.ReadOnlyRole)] public async Task> DeleteChapter(int chapterId) { var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.ExternalReviews | ChapterIncludes.ExternalRatings); if (chapter == null) return BadRequest(localizationService.Translate(UserId, "chapter-doesnt-exist")); var vol = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId, VolumeIncludes.Chapters); if (vol == null) return BadRequest(localizationService.Translate(UserId, "volume-doesnt-exist")); // If there is only 1 chapter within the volume, then we need to remove the volume var needToRemoveVolume = vol.Chapters.Count == 1; if (needToRemoveVolume) { unitOfWork.VolumeRepository.Remove(vol); } else { unitOfWork.ChapterRepository.Remove(chapter); } // If we removed the volume, do an additional check if we need to delete the actual series as well or not var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(vol.SeriesId, SeriesIncludes.ExternalData | SeriesIncludes.Volumes); var needToRemoveSeries = needToRemoveVolume && series != null && series.Volumes.Count <= 1; if (needToRemoveSeries) { unitOfWork.SeriesRepository.Remove(series!); } if (!await unitOfWork.CommitAsync()) return Ok(false); await eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, vol.SeriesId), false); if (needToRemoveVolume) { await eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(chapter.VolumeId, vol.SeriesId), false); } if (needToRemoveSeries) { await eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, MessageFactory.SeriesRemovedEvent(series!.Id, series.Name, series.LibraryId), false); } return Ok(true); } /// /// Deletes multiple chapters and any volumes with no leftover chapters /// /// The ID of the series /// The IDs of the chapters to be deleted /// [HttpPost("delete-multiple")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task> DeleteMultipleChapters([FromQuery] int seriesId, DeleteChaptersDto dto) { try { var chapterIds = dto.ChapterIds; if (chapterIds == null || chapterIds.Count == 0) { return BadRequest("ChapterIds required"); } // Fetch all chapters to be deleted var chapters = (await unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)).ToList(); // Group chapters by their volume var volumesToUpdate = chapters.GroupBy(c => c.VolumeId).ToList(); var removedVolumes = new List(); foreach (var volumeGroup in volumesToUpdate) { var volumeId = volumeGroup.Key; var chaptersToDelete = volumeGroup.ToList(); // Fetch the volume var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId, VolumeIncludes.Chapters); if (volume == null) return BadRequest(localizationService.Translate(UserId, "volume-doesnt-exist")); // Check if all chapters in the volume are being deleted var isVolumeToBeRemoved = volume.Chapters.Count == chaptersToDelete.Count; if (isVolumeToBeRemoved) { unitOfWork.VolumeRepository.Remove(volume); removedVolumes.Add(volume.Id); } else { // Remove only the specified chapters unitOfWork.ChapterRepository.Remove(chaptersToDelete); } } if (!await unitOfWork.CommitAsync()) return Ok(false); // Send events for removed chapters foreach (var chapter in chapters) { await eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, seriesId), false); } // Send events for removed volumes foreach (var volumeId in removedVolumes) { await eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volumeId, seriesId), false); } return Ok(true); } catch (Exception ex) { logger.LogError(ex, "An error occured while deleting chapters"); return BadRequest(localizationService.Translate(UserId, "generic-error")); } } /// /// Update chapter metadata /// /// /// [HttpPost("update")] [Authorize(Policy = PolicyGroups.AdminPolicy)] public async Task UpdateChapterMetadata(UpdateChapterDto dto) { var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(dto.Id, ChapterIncludes.People | ChapterIncludes.Genres | ChapterIncludes.Tags, HttpContext.RequestAborted); if (chapter == null) return BadRequest(localizationService.Translate(UserId, "chapter-doesnt-exist")); var seriesId = await unitOfWork.ChapterRepository.GetSeriesIdForChapter(chapter.Id, HttpContext.RequestAborted); if (chapter.AgeRating != dto.AgeRating) { chapter.AgeRating = dto.AgeRating; chapter.KPlusOverrides.Remove(MetadataSettingField.AgeRating); } dto.Summary ??= string.Empty; if (chapter.Summary != dto.Summary.Trim()) { chapter.Summary = dto.Summary.Trim(); chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterSummary); } if (chapter.Language != dto.Language) { chapter.Language = dto.Language ?? string.Empty; } if (chapter.SortOrder.IsNot(dto.SortOrder)) { chapter.SortOrder = dto.SortOrder; // TODO: Figure out validation } if (chapter.TitleName != dto.TitleName) { chapter.TitleName = dto.TitleName; chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterTitle); } if (chapter.ReleaseDate != dto.ReleaseDate) { chapter.ReleaseDate = dto.ReleaseDate; chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterReleaseDate); } if (!string.IsNullOrEmpty(dto.ISBN) && ArticleNumberHelper.IsValidIsbn10(dto.ISBN) || ArticleNumberHelper.IsValidIsbn13(dto.ISBN)) { chapter.ISBN = dto.ISBN; } if (string.IsNullOrEmpty(dto.WebLinks)) { chapter.WebLinks = string.Empty; } else { chapter.WebLinks = string.Join(',', dto.WebLinks .Split(',') .Where(s => !string.IsNullOrEmpty(s)) .Select(s => s.Trim())! ); } #region Genres chapter.Genres ??= []; await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), unitOfWork); #endregion #region Tags chapter.Tags ??= []; await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), unitOfWork); #endregion #region People chapter.People ??= []; // Update writers await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Writers.Select(p => p.Name).ToList(), PersonRole.Writer, unitOfWork ); // Update characters await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Characters.Select(p => p.Name).ToList(), PersonRole.Character, unitOfWork ); // Update pencillers await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Pencillers.Select(p => p.Name).ToList(), PersonRole.Penciller, unitOfWork ); // Update inkers await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Inkers.Select(p => p.Name).ToList(), PersonRole.Inker, unitOfWork ); // Update colorists await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Colorists.Select(p => p.Name).ToList(), PersonRole.Colorist, unitOfWork ); // Update letterers await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Letterers.Select(p => p.Name).ToList(), PersonRole.Letterer, unitOfWork ); // Update cover artists await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.CoverArtists.Select(p => p.Name).ToList(), PersonRole.CoverArtist, unitOfWork ); // Update editors await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Editors.Select(p => p.Name).ToList(), PersonRole.Editor, unitOfWork ); // Update publishers var updatedPublishers = await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Publishers.Select(p => p.Name).ToList(), PersonRole.Publisher, unitOfWork ); if (updatedPublishers) chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterPublisher); // Update translators await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Translators.Select(p => p.Name).ToList(), PersonRole.Translator, unitOfWork ); // Update imprints await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Imprints.Select(p => p.Name).ToList(), PersonRole.Imprint, unitOfWork ); // Update teams await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Teams.Select(p => p.Name).ToList(), PersonRole.Team, unitOfWork ); // Update locations await PersonHelper.UpdateChapterPeopleAsync( chapter, dto.Locations.Select(p => p.Name).ToList(), PersonRole.Location, unitOfWork ); #endregion #region Locks chapter.AgeRatingLocked = dto.AgeRatingLocked; chapter.LanguageLocked = dto.LanguageLocked; chapter.TitleNameLocked = dto.TitleNameLocked; chapter.SortOrderLocked = dto.SortOrderLocked; chapter.GenresLocked = dto.GenresLocked; chapter.TagsLocked = dto.TagsLocked; chapter.CharacterLocked = dto.CharacterLocked; chapter.ColoristLocked = dto.ColoristLocked; chapter.EditorLocked = dto.EditorLocked; chapter.InkerLocked = dto.InkerLocked; chapter.ImprintLocked = dto.ImprintLocked; chapter.LettererLocked = dto.LettererLocked; chapter.PencillerLocked = dto.PencillerLocked; chapter.PublisherLocked = dto.PublisherLocked; chapter.TranslatorLocked = dto.TranslatorLocked; chapter.CoverArtistLocked = dto.CoverArtistLocked; chapter.WriterLocked = dto.WriterLocked; chapter.SummaryLocked = dto.SummaryLocked; chapter.ISBNLocked = dto.ISBNLocked; chapter.ReleaseDateLocked = dto.ReleaseDateLocked; #endregion unitOfWork.ChapterRepository.Update(chapter); if (!unitOfWork.HasChanges()) { return Ok(); } if (seriesId.HasValue) { await eventHub.SendMessageAsync(MessageFactory.ChapterUpdated, MessageFactory.ChapterUpdatedEvent(chapter.Id, seriesId.Value), false, HttpContext.RequestAborted); } await unitOfWork.CommitAsync(); return Ok(); } /// /// Returns Ratings and Reviews for an individual Chapter /// /// /// [ChapterAccess] [HttpGet("chapter-detail-plus")] public async Task> ChapterDetailPlus([FromQuery] int chapterId) { var ret = new ChapterDetailPlusDto(); var userReviews = (await unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, UserId)) .Where(r => !string.IsNullOrEmpty(r.Body)) .OrderByDescending(review => review.Username.Equals(Username!) ? 1 : 0) .ToList(); var ownRating = await unitOfWork.UserRepository.GetUserChapterRatingAsync(UserId, chapterId); if (ownRating != null) { ret.Rating = ownRating.Rating; ret.HasBeenRated = ownRating.HasBeenRated; } var externalReviews = await unitOfWork.ChapterRepository.GetExternalChapterReviewDtos(chapterId); if (externalReviews.Count > 0) { userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(externalReviews)); } ret.Reviews = userReviews; ret.Ratings = await unitOfWork.ChapterRepository.GetExternalChapterRatingDtos(chapterId); return Ok(ret); } }