using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Comparators; using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.Person; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; using API.Entities.MetadataMatching; using API.Entities.Person; using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Helpers.Formatting; using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Kavita.Common; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services; #nullable enable public interface ISeriesService { Task GetSeriesDetail(int seriesId, int userId); Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto); Task DeleteMultipleSeries(IList seriesIds); Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto); Task GetRelatedSeries(int userId, int seriesId); Task GetEstimatedChapterCreationDate(int seriesId, int userId); Task> GetCurrentlyReading(int userId, int requestingUserId, UserParams userParams); Task> GetProfilePrivacyStatements(int userId, int requestingUserId); } public class SeriesService : ISeriesService { private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly ITaskScheduler _taskScheduler; private readonly ILogger _logger; private readonly ILocalizationService _localizationService; private readonly IReadingListService _readingListService; private readonly IEntityNamingService _namingService; private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto { ExpectedDate = null, ChapterNumber = 0, VolumeNumber = Parser.LooseLeafVolumeNumber }; public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, ILogger logger, ILocalizationService localizationService, IReadingListService readingListService, IEntityNamingService namingService) { _unitOfWork = unitOfWork; _eventHub = eventHub; _taskScheduler = taskScheduler; _logger = logger; _localizationService = localizationService; _readingListService = readingListService; _namingService = namingService; } /// /// Returns the first chapter for a series to extract metadata from (ie Summary, etc.) /// /// The full series with all volumes and chapters on it /// public static Chapter? GetFirstChapterForMetadata(Series series) { var sortedVolumes = series.Volumes .Where(v => v.MinNumber.IsNot(Parser.LooseLeafVolumeNumber)) .OrderBy(v => v.MinNumber); var minVolumeNumber = sortedVolumes.MinBy(v => v.MinNumber); var allChapters = series.Volumes .SelectMany(v => v.Chapters.OrderBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default)) .ToList(); var minChapter = allChapters .FirstOrDefault(); if (minVolumeNumber != null && minChapter != null && (minChapter.MinNumber >= minVolumeNumber.MinNumber || minChapter.MinNumber.Is(Parser.DefaultChapterNumber))) { return minVolumeNumber.Chapters.MinBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default); } return minChapter; } /// /// Updates the Series Metadata. /// /// /// public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) { try { var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId; var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata); if (series == null) return false; series.Metadata ??= new SeriesMetadataBuilder() .Build(); if (NumberHelper.IsValidYear(updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) { series.Metadata.ReleaseYear = updateSeriesMetadataDto.SeriesMetadata.ReleaseYear; series.Metadata.ReleaseYearLocked = true; series.Metadata.KPlusOverrides.Remove(MetadataSettingField.StartDate); } if (series.Metadata.PublicationStatus != updateSeriesMetadataDto.SeriesMetadata.PublicationStatus) { series.Metadata.PublicationStatus = updateSeriesMetadataDto.SeriesMetadata.PublicationStatus; series.Metadata.PublicationStatusLocked = true; series.Metadata.KPlusOverrides.Remove(MetadataSettingField.PublicationStatus); } if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata.Summary)) { updateSeriesMetadataDto.SeriesMetadata.Summary = string.Empty; series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Summary); } if (series.Metadata.Summary != updateSeriesMetadataDto.SeriesMetadata.Summary.Trim()) { series.Metadata.Summary = updateSeriesMetadataDto.SeriesMetadata?.Summary.Trim() ?? string.Empty; series.Metadata.SummaryLocked = true; series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Summary); } if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata?.Language) { series.Metadata.Language = updateSeriesMetadataDto.SeriesMetadata?.Language ?? string.Empty; series.Metadata.LanguageLocked = true; } if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata?.WebLinks)) { series.Metadata.WebLinks = string.Empty; } else { series.Metadata.WebLinks = string.Join(',', updateSeriesMetadataDto.SeriesMetadata?.WebLinks .Split(',') .Where(s => !string.IsNullOrEmpty(s)) .Select(s => s.Trim())! ); } if (updateSeriesMetadataDto.SeriesMetadata?.Genres != null && updateSeriesMetadataDto.SeriesMetadata.Genres.Count != 0) { var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(updateSeriesMetadataDto.SeriesMetadata.Genres.Select(t => Parser.Normalize(t.Title)))).ToList(); series.Metadata.Genres ??= []; GenreHelper.UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, genre => { series.Metadata.Genres.Add(genre); }, () => series.Metadata.GenresLocked = true); } else { series.Metadata.Genres = []; } if (updateSeriesMetadataDto.SeriesMetadata?.Tags is {Count: > 0}) { var allTags = (await _unitOfWork.TagRepository .GetAllTagsByNameAsync(updateSeriesMetadataDto.SeriesMetadata.Tags.Select(t => Parser.Normalize(t.Title)))) .ToList(); series.Metadata.Tags ??= []; TagHelper.UpdateTagList(updateSeriesMetadataDto.SeriesMetadata?.Tags, series, allTags, tag => { series.Metadata.Tags.Add(tag); }, () => series.Metadata.TagsLocked = true); } else { series.Metadata.Tags = []; } if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata?.AgeRating) { series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata?.AgeRating ?? AgeRating.Unknown; series.Metadata.AgeRatingLocked = true; await _readingListService.UpdateReadingListAgeRatingForSeries(series.Id, series.Metadata.AgeRating); series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating); } else { if (!series.Metadata.AgeRatingLocked) { var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); if (metadataSettings.EnableExtendedMetadataProcessing) { var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); if (updatedRating > series.Metadata.AgeRating) { series.Metadata.AgeRating = updatedRating; series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating); } } } } // Update people and locks if (updateSeriesMetadataDto.SeriesMetadata != null) { series.Metadata.People ??= []; // Writers if (!series.Metadata.WriterLocked || !updateSeriesMetadataDto.SeriesMetadata.WriterLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer, _unitOfWork); } // Cover Artists if (!series.Metadata.CoverArtistLocked || !updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist, _unitOfWork); } // Colorists if (!series.Metadata.ColoristLocked || !updateSeriesMetadataDto.SeriesMetadata.ColoristLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist, _unitOfWork); } // Editors if (!series.Metadata.EditorLocked || !updateSeriesMetadataDto.SeriesMetadata.EditorLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor, _unitOfWork); } // Inkers if (!series.Metadata.InkerLocked || !updateSeriesMetadataDto.SeriesMetadata.InkerLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker, _unitOfWork); } // Letterers if (!series.Metadata.LettererLocked || !updateSeriesMetadataDto.SeriesMetadata.LettererLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer, _unitOfWork); } // Pencillers if (!series.Metadata.PencillerLocked || !updateSeriesMetadataDto.SeriesMetadata.PencillerLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller, _unitOfWork); } // Publishers if (!series.Metadata.PublisherLocked || !updateSeriesMetadataDto.SeriesMetadata.PublisherLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher, _unitOfWork); } // Imprints if (!series.Metadata.ImprintLocked || !updateSeriesMetadataDto.SeriesMetadata.ImprintLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint, _unitOfWork); } // Teams if (!series.Metadata.TeamLocked || !updateSeriesMetadataDto.SeriesMetadata.TeamLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team, _unitOfWork); } // Locations if (!series.Metadata.LocationLocked || !updateSeriesMetadataDto.SeriesMetadata.LocationLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location, _unitOfWork); } // Translators if (!series.Metadata.TranslatorLocked || !updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator, _unitOfWork); } // Characters if (!series.Metadata.CharacterLocked || !updateSeriesMetadataDto.SeriesMetadata.CharacterLocked) { await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Characters, PersonRole.Character, _unitOfWork); } series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked; series.Metadata.PublicationStatusLocked = updateSeriesMetadataDto.SeriesMetadata.PublicationStatusLocked; series.Metadata.LanguageLocked = updateSeriesMetadataDto.SeriesMetadata.LanguageLocked; series.Metadata.GenresLocked = updateSeriesMetadataDto.SeriesMetadata.GenresLocked; series.Metadata.TagsLocked = updateSeriesMetadataDto.SeriesMetadata.TagsLocked; series.Metadata.CharacterLocked = updateSeriesMetadataDto.SeriesMetadata.CharacterLocked; series.Metadata.ColoristLocked = updateSeriesMetadataDto.SeriesMetadata.ColoristLocked; series.Metadata.EditorLocked = updateSeriesMetadataDto.SeriesMetadata.EditorLocked; series.Metadata.InkerLocked = updateSeriesMetadataDto.SeriesMetadata.InkerLocked; series.Metadata.ImprintLocked = updateSeriesMetadataDto.SeriesMetadata.ImprintLocked; series.Metadata.LettererLocked = updateSeriesMetadataDto.SeriesMetadata.LettererLocked; series.Metadata.PencillerLocked = updateSeriesMetadataDto.SeriesMetadata.PencillerLocked; series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublisherLocked; series.Metadata.TranslatorLocked = updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked; series.Metadata.LocationLocked = updateSeriesMetadataDto.SeriesMetadata.LocationLocked; series.Metadata.CoverArtistLocked = updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked; series.Metadata.WriterLocked = updateSeriesMetadataDto.SeriesMetadata.WriterLocked; series.Metadata.SummaryLocked = updateSeriesMetadataDto.SeriesMetadata.SummaryLocked; series.Metadata.ReleaseYearLocked = updateSeriesMetadataDto.SeriesMetadata.ReleaseYearLocked; } if (!_unitOfWork.HasChanges()) { return true; } _unitOfWork.SeriesRepository.Update(series.Metadata); await _unitOfWork.CommitAsync(); // Trigger code to clean up tags, collections, people, etc try { await _taskScheduler.CleanupDbEntries(); } catch (Exception ex) { _logger.LogError(ex, "There was an issue cleaning up DB entries. This may happen if Komf is spamming updates. Nightly cleanup will work"); } return true; } catch (Exception ex) { _logger.LogError(ex, "There was an exception when updating metadata"); await _unitOfWork.RollbackAsync(); } return false; } /// /// Exclusively for Series Update API /// /// /// /// public static async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection peopleDtos, PersonRole role, IUnitOfWork unitOfWork) { // TODO: Cleanup this code so we aren't using UnitOfWork like this // Normalize all names from the DTOs var normalizedNames = peopleDtos .Select(p => Parser.Normalize(p.Name)) .Distinct() .ToList(); // Bulk select people who already exist in the database var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); // Use a dictionary for quick lookups var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople); // List to track people that will be added to the metadata var peopleToAdd = new List(); foreach (var personDto in peopleDtos) { var normalizedPersonName = Parser.Normalize(personDto.Name); // Check if the person exists in the dictionary if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p)) { // TODO: Should I add more controls here to map back? if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId) { p.AniListId = personDto.AniListId; } p.Description = string.IsNullOrEmpty(p.Description) ? personDto.Description : p.Description; continue; // If we ever want to update metadata for existing people, we'd do it here } // Person doesn't exist, so create a new one var newPerson = new Person { Name = personDto.Name, NormalizedName = normalizedPersonName, AniListId = personDto.AniListId, Description = personDto.Description, Asin = personDto.Asin, CoverImage = personDto.CoverImage, MalId = personDto.MalId, HardcoverId = personDto.HardcoverId, }; peopleToAdd.Add(newPerson); existingPeopleDictionary[normalizedPersonName] = newPerson; } // Add any new people to the database in bulk if (peopleToAdd.Count != 0) { unitOfWork.PersonRepository.Attach(peopleToAdd); } // Now that we have all the people (new and existing), update the SeriesMetadataPeople UpdateSeriesMetadataPeople(metadata, metadata.People, existingPeopleDictionary.Values, role); } private static void UpdateSeriesMetadataPeople(SeriesMetadata metadata, ICollection metadataPeople, IEnumerable people, PersonRole role) { var peopleToAdd = people.ToList(); // Remove any people in the existing metadataPeople for this role that are no longer present in the input list var peopleToRemove = metadataPeople .Where(mp => mp.Role == role && peopleToAdd.TrueForAll(p => p.NormalizedName != mp.Person.NormalizedName)) .ToList(); foreach (var personToRemove in peopleToRemove) { metadataPeople.Remove(personToRemove); } // Add new people for this role if they don't already exist foreach (var person in peopleToAdd) { var existingPersonEntry = metadataPeople .FirstOrDefault(mp => mp.Person.NormalizedName == person.NormalizedName && mp.Role == role); if (existingPersonEntry == null) { metadataPeople.Add(new SeriesMetadataPeople { PersonId = person.Id, Person = person, SeriesMetadataId = metadata.Id, SeriesMetadata = metadata, Role = role }); } } } public async Task DeleteMultipleSeries(IList seriesIds) { try { var chapterMappings = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync([.. seriesIds]); var allChapterIds = new List(); foreach (var mapping in chapterMappings) { allChapterIds.AddRange(mapping.Value); } // NOTE: This isn't getting all the people and whatnot currently due to the lack of includes var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(seriesIds); _unitOfWork.SeriesRepository.Remove(series); var libraryIds = series.Select(s => s.LibraryId); var libraries = await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(libraryIds); foreach (var library in libraries) { library.UpdateLastModified(); _unitOfWork.LibraryRepository.Update(library); } await _unitOfWork.CommitAsync(); foreach (var s in series) { await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, MessageFactory.SeriesRemovedEvent(s.Id, s.Name, s.LibraryId), false); } await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); _taskScheduler.CleanupChapters([.. allChapterIds]); return true; } catch (Exception ex) { _logger.LogError(ex, "There was an issue when trying to delete multiple series"); return false; } } /// /// This generates all the arrays needed by the Series Detail page in the UI. It is a specialized API for the unique layout constraints. /// /// /// /// public async Task GetSeriesDetail(int seriesId, int userId) { var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); var libraryIds = await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId); if (!libraryIds.Contains(series.LibraryId)) throw new UnauthorizedAccessException("user-no-access-library-from-series"); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user!.AgeRestriction != AgeRating.NotApplicable) { var seriesMetadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId); if (seriesMetadata!.AgeRating > user.AgeRestriction) throw new UnauthorizedAccessException("series-restricted-age-restriction"); } var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId); var namingContext = await LocalizedNamingContext.CreateAsync(_namingService, _localizationService, userId, libraryType); var bookTreatment = libraryType is LibraryType.Book or LibraryType.LightNovel; // For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number. var processedVolumes = new List(); foreach (var volume in volumes) { if (volume.IsLooseLeaf() || volume.IsSpecial()) { continue; } var formattedName = namingContext.FormatVolumeName(volume); if (formattedName != null) { volume.Name = formattedName; processedVolumes.Add(volume); } else if (bookTreatment && !volume.IsSpecial()) { // Edge case: FormatVolumeName returned null but book treatment wants it processedVolumes.Add(volume); } } var specials = new List(); // Why isn't this doing a check if chapter is not special as it wont get included var chapters = volumes .SelectMany(v => v.Chapters .Select(c => { if (v.IsLooseLeaf() || v.IsSpecial()) return c; c.VolumeTitle = v.Name; return c; }) .OrderBy(c => c.SortOrder)) .ToList(); foreach (var chapter in chapters) { chapter.Title = namingContext.FormatChapterTitle(chapter); if (!chapter.IsSpecial) continue; specials.Add(chapter); } // Don't show chapter -100000 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes) IEnumerable retChapters = bookTreatment ? [] : chapters.Where(ShouldIncludeChapter); var storylineChapters = volumes .WhereLooseLeaf() .SelectMany(v => v.Chapters.Where(c => !c.IsSpecial)) .OrderBy(c => c.SortOrder) .ToList(); // When there's chapters without a volume number revert to chapter sorting only as opposed to volume then chapter if (storylineChapters.Count > 0) { retChapters = retChapters.OrderBy(c => c.SortOrder, ChapterSortComparerDefaultLast.Default); } return new SeriesDetailDto { Specials = specials, Chapters = retChapters, Volumes = processedVolumes, StorylineChapters = storylineChapters, TotalCount = chapters.Count, UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages), // TODO: See if we can get the ContinueFrom here }; } /// /// Should we show the given chapter on the UI. We only show non-specials and non-zero chapters. /// /// /// private static bool ShouldIncludeChapter(ChapterDto chapter) { return !chapter.IsSpecial && chapter.MinNumber.IsNot(Parser.DefaultChapterNumber); } /// /// Returns all related series against the passed series Id /// /// /// /// public async Task GetRelatedSeries(int userId, int seriesId) { return await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId); } /// /// Update the relations attached to the Series. Generates associated Sequel/Prequel pairs on target series. /// /// /// public async Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto) { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Related); if (series == null) return false; UpdateRelationForKind(dto.Adaptations, series.Relations.Where(r => r.RelationKind == RelationKind.Adaptation).ToList(), series, RelationKind.Adaptation); UpdateRelationForKind(dto.Characters, series.Relations.Where(r => r.RelationKind == RelationKind.Character).ToList(), series, RelationKind.Character); UpdateRelationForKind(dto.Contains, series.Relations.Where(r => r.RelationKind == RelationKind.Contains).ToList(), series, RelationKind.Contains); UpdateRelationForKind(dto.Others, series.Relations.Where(r => r.RelationKind == RelationKind.Other).ToList(), series, RelationKind.Other); UpdateRelationForKind(dto.SideStories, series.Relations.Where(r => r.RelationKind == RelationKind.SideStory).ToList(), series, RelationKind.SideStory); UpdateRelationForKind(dto.SpinOffs, series.Relations.Where(r => r.RelationKind == RelationKind.SpinOff).ToList(), series, RelationKind.SpinOff); UpdateRelationForKind(dto.AlternativeSettings, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeSetting).ToList(), series, RelationKind.AlternativeSetting); UpdateRelationForKind(dto.AlternativeVersions, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeVersion).ToList(), series, RelationKind.AlternativeVersion); UpdateRelationForKind(dto.Doujinshis, series.Relations.Where(r => r.RelationKind == RelationKind.Doujinshi).ToList(), series, RelationKind.Doujinshi); UpdateRelationForKind(dto.Editions, series.Relations.Where(r => r.RelationKind == RelationKind.Edition).ToList(), series, RelationKind.Edition); UpdateRelationForKind(dto.Annuals, series.Relations.Where(r => r.RelationKind == RelationKind.Annual).ToList(), series, RelationKind.Annual); await UpdatePrequelSequelRelations(dto.Prequels, series, RelationKind.Prequel); await UpdatePrequelSequelRelations(dto.Sequels, series, RelationKind.Sequel); if (!_unitOfWork.HasChanges()) return true; return await _unitOfWork.CommitAsync(); } /// /// Updates Prequel/Sequel relations and creates reciprocal relations on target series. /// /// List of target series IDs /// The current series being updated /// The relation kind (Prequel or Sequel) private async Task UpdatePrequelSequelRelations(ICollection targetSeriesIds, Series series, RelationKind kind) { var existingRelations = series.Relations.Where(r => r.RelationKind == kind).ToList(); // Remove relations that are not in the new list foreach (var relation in existingRelations.Where(relation => !targetSeriesIds.Contains(relation.TargetSeriesId))) { series.Relations.Remove(relation); await RemoveReciprocalRelation(series.Id, relation.TargetSeriesId, GetOppositeRelationKind(kind)); } // Add new relations foreach (var targetSeriesId in targetSeriesIds) { if (series.Relations.Any(r => r.RelationKind == kind && r.TargetSeriesId == targetSeriesId)) continue; series.Relations.Add(new SeriesRelation { Series = series, SeriesId = series.Id, TargetSeriesId = targetSeriesId, RelationKind = kind }); await AddReciprocalRelation(series.Id, targetSeriesId, GetOppositeRelationKind(kind)); } _unitOfWork.SeriesRepository.Update(series); } private static RelationKind GetOppositeRelationKind(RelationKind kind) { return kind == RelationKind.Prequel ? RelationKind.Sequel : RelationKind.Prequel; } private async Task AddReciprocalRelation(int sourceSeriesId, int targetSeriesId, RelationKind kind) { var targetSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(targetSeriesId, SeriesIncludes.Related); if (targetSeries == null) return; if (targetSeries.Relations.Any(r => r.RelationKind == kind && r.TargetSeriesId == sourceSeriesId)) return; targetSeries.Relations.Add(new SeriesRelation { Series = targetSeries, SeriesId = targetSeriesId, TargetSeriesId = sourceSeriesId, RelationKind = kind }); _unitOfWork.SeriesRepository.Update(targetSeries); } private async Task RemoveReciprocalRelation(int sourceSeriesId, int targetSeriesId, RelationKind kind) { var targetSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(targetSeriesId, SeriesIncludes.Related); if (targetSeries == null) return; var relationToRemove = targetSeries.Relations.FirstOrDefault(r => r.RelationKind == kind && r.TargetSeriesId == sourceSeriesId); if (relationToRemove != null) { targetSeries.Relations.Remove(relationToRemove); _unitOfWork.SeriesRepository.Update(targetSeries); } } /// /// Applies the provided list to the series. Adds new relations and removes deleted relations. /// /// /// /// /// private void UpdateRelationForKind(ICollection dtoTargetSeriesIds, IEnumerable adaptations, Series series, RelationKind kind) { foreach (var adaptation in adaptations.Where(adaptation => !dtoTargetSeriesIds.Contains(adaptation.TargetSeriesId))) { // If the seriesId isn't in dto, it means we've removed or reclassified series.Relations.Remove(adaptation); } // At this point, we only have things to add foreach (var targetSeriesId in dtoTargetSeriesIds) { // This ensures we don't allow any duplicates to be added if (series.Relations.SingleOrDefault(r => r.RelationKind == kind && r.TargetSeriesId == targetSeriesId) != null) continue; series.Relations.Add(new SeriesRelation { Series = series, SeriesId = series.Id, TargetSeriesId = targetSeriesId, RelationKind = kind }); _unitOfWork.SeriesRepository.Update(series); } } public async Task GetEstimatedChapterCreationDate(int seriesId, int userId) { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); if (!(await _unitOfWork.UserRepository.HasAccessToSeries(userId, seriesId))) { throw new UnauthorizedAccessException("user-no-access-library-from-series"); } // Estimation only makes sense for ongoing/ended manga/comics - books and light novels // don't follow predictable release patterns based on chapter creation dates if (series.Metadata.PublicationStatus is not (PublicationStatus.OnGoing or PublicationStatus.Ended) || (series.Library.Type is LibraryType.Book or LibraryType.LightNovel)) { return _emptyExpectedChapter; } // We need at least 3 chapters to establish a meaningful pattern for prediction. // With fewer data points, exponential smoothing produces unreliable forecasts. const int minimumChaptersRequired = 3; const int minimumTimeDeltas = 3; // Only fetch the fields we need for calculation - avoids loading entire Chapter entities // with all their navigation properties, significantly reducing memory and query time var chapterData = await _unitOfWork.ChapterRepository.GetChaptersForSeries(seriesId) .Where(c => !c.IsSpecial) .Select(c => new { c.CreatedUtc, c.MaxNumber, VolumeMinNumber = c.Volume.MinNumber }) .OrderBy(c => c.CreatedUtc) .ToListAsync(); if (chapterData.Count < minimumChaptersRequired) return _emptyExpectedChapter; // Pre-allocate with maximum possible capacity to avoid list resizing during iteration. // We store as days (double) directly to avoid a second conversion pass later. var timeDifferencesInDays = new List(chapterData.Count - 1); // These track the values we need for building the result DTO. // Previously we iterated the list 4 separate times with LINQ - now we gather everything in one pass. var lastChapterDate = DateTime.MinValue; var highestChapterNumber = float.MinValue; var highestChapterIndex = 0; var highestVolumeNumber = float.MinValue; DateTime? previousChapterTime = null; for (var i = 0; i < chapterData.Count; i++) { var chapter = chapterData[i]; // Find the most recently created chapter - used as the baseline for our prediction. // "When was the last chapter added?" + forecasted interval = expected next chapter date if (chapter.CreatedUtc > lastChapterDate) { lastChapterDate = chapter.CreatedUtc; } // Find the chapter with the highest number - this determines what number comes next. // Note: This is different from "most recent" - a user might add Chapter 50 before Chapter 49 if (chapter.MaxNumber > highestChapterNumber) { highestChapterNumber = chapter.MaxNumber; highestChapterIndex = i; } // Track the highest volume number for series that use volume-based numbering // (e.g., "Volume 5" instead of "Chapter 47") if (chapter.VolumeMinNumber > highestVolumeNumber) { highestVolumeNumber = chapter.VolumeMinNumber; } // Build the time differences array for exponential smoothing. // We skip chapters added within 1 hour of each other - these are typically bulk imports // or batch uploads that don't represent the actual release cadence. if (previousChapterTime.HasValue) { var daysBetweenChapters = (chapter.CreatedUtc - previousChapterTime.Value).TotalDays; var hoursBetweenChapters = daysBetweenChapters * 24; if (hoursBetweenChapters > 1) { timeDifferencesInDays.Add(daysBetweenChapters); previousChapterTime = chapter.CreatedUtc; } // If within an hour, we intentionally don't update previousChapterTime // so the next valid chapter measures from the last "real" release } else { previousChapterTime = chapter.CreatedUtc; } } // After filtering out bulk imports, we may not have enough data points for a reliable forecast if (timeDifferencesInDays.Count < minimumTimeDeltas) { return _emptyExpectedChapter; } // Exponential smoothing weights recent releases more heavily than older ones. // Alpha of 0.2 means ~80% weight on historical average, ~20% on recent data. // This smooths out anomalies like holiday delays or double-releases. const double alpha = 0.2; var forecastedDaysBetweenChapters = ExponentialSmoothing(timeDifferencesInDays, alpha); if (forecastedDaysBetweenChapters <= 0) { return _emptyExpectedChapter; } // Calculate expected date by adding the forecasted interval to the last chapter's creation date var estimatedDate = lastChapterDate.AddDays(forecastedDaysBetweenChapters); // Clamp to valid calendar date - AddDays can produce invalid dates like "February 30th" // when the forecasted date would land past the end of a short month var nextChapterExpected = estimatedDate.Day > DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month) ? new DateTime(estimatedDate.Year, estimatedDate.Month, DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month)) : estimatedDate; // Build the result with the next expected chapter/volume number var result = new NextExpectedChapterDto { ChapterNumber = 0, VolumeNumber = Parser.LooseLeafVolumeNumber, ExpectedDate = nextChapterExpected, Title = string.Empty }; // Series can be numbered by chapter (most manga/comics) or by volume (some collected editions). // If we have chapter numbers, increment from the highest; otherwise, increment the volume number. if (highestChapterNumber > 0) { result.ChapterNumber = (int)Math.Truncate(highestChapterNumber) + 1; result.VolumeNumber = chapterData[highestChapterIndex].VolumeMinNumber; // Format the title based on library type conventions // Manga uses "Chapter X", Comics use "Issue #X", Books use "Book X" result.Title = series.Library.Type switch { LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber), LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber), LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber), LibraryType.Book => await _localizationService.Translate(userId, "book-num", result.ChapterNumber), LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", result.ChapterNumber), _ => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber) }; } else { // Volume-only numbering - common for omnibus editions or series without chapter breaks result.VolumeNumber = (int)highestVolumeNumber + 1; result.Title = await _localizationService.Translate(userId, "volume-num", result.VolumeNumber); } return result; } public async Task> GetCurrentlyReading(int userId, int requestingUserId, UserParams userParams) { var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var filter = new FilterV2Dto { Combination = FilterCombination.And, SortOptions = new SortOptions { SortField = SortField.ReadProgress, IsAscending = false, }, Statements = [ new FilterStatementDto { Comparison = FilterComparison.GreaterThan, Field = FilterField.ReadLast, Value = serverSettings.OnDeckProgressDays.ToString(), }, new FilterStatementDto { Comparison = FilterComparison.LessThan, Field = FilterField.ReadProgress, Value = "100", }, new FilterStatementDto { Comparison = FilterComparison.GreaterThan, Field = FilterField.ReadProgress, Value = "0", }, ], }; filter.Statements.AddRange(await GetProfilePrivacyStatements(userId, requestingUserId)); return await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filter); } public async Task> GetProfilePrivacyStatements(int userId, int requestingUserId) { if (userId == requestingUserId) return []; var socialPreferences = await _unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); var requestingUser = (await _unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId))!; var librariesUser = await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId); var librariesRequestingUser = await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(requestingUserId); var libIds = librariesRequestingUser.Intersect(librariesUser); if (socialPreferences.SocialLibraries.Count > 0) { libIds = libIds.Intersect(socialPreferences.SocialLibraries); } var libraries = libIds.Select(id => id.ToString()); var ageRating = socialPreferences.SocialMaxAgeRating < requestingUser.AgeRestriction ? socialPreferences.SocialMaxAgeRating : requestingUser.AgeRestriction; var includeUnknowns = socialPreferences.SocialIncludeUnknowns && requestingUser.AgeRestrictionIncludeUnknowns; List filters = [ new() { Comparison = FilterComparison.Contains, Field = FilterField.Libraries, Value = string.Join(',', libraries), } ]; if (!includeUnknowns) { filters.Add(new FilterStatementDto { Comparison = FilterComparison.NotEqual, Field = FilterField.AgeRating, Value = nameof(AgeRating.Unknown), }); } if (ageRating != AgeRating.NotApplicable) { filters.Add(new FilterStatementDto { Comparison = FilterComparison.LessThanEqual, Field = FilterField.AgeRating, Value = ageRating.ToString(), }); } return filters; } private static double ExponentialSmoothing(IList data, double alpha) { var forecast = data[0]; foreach (var value in data) { forecast = alpha * value + (1 - alpha) * forecast; } return forecast; } }