using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using AutoMapper; using AutoMapper.QueryableExtensions; using Kavita.API.Repositories; using Kavita.Database.Extensions; using Kavita.Models.Constants; using Kavita.Models.DTOs; using Kavita.Models.DTOs.Metadata; using Kavita.Models.DTOs.Reader; using Kavita.Models.DTOs.SeriesDetail; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.Metadata; using Kavita.Models.Extensions; using Microsoft.EntityFrameworkCore; namespace Kavita.Database.Repositories; public class ChapterRepository(DataContext context, IMapper mapper) : IChapterRepository { public void Update(Chapter chapter) { context.Entry(chapter).State = EntityState.Modified; } public void Remove(Chapter chapter) { context.Chapter.Remove(chapter); } public void Remove(IList chapters) { context.Chapter.RemoveRange(chapters); } public async Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None, CancellationToken ct = default) { return await context.Chapter .Where(c => chapterIds.Contains(c.Id)) .Includes(includes) .AsSplitQuery() .ToListAsync(ct); } /// /// Populates a partial IChapterInfoDto /// /// public async Task GetChapterInfoDtoAsync(int chapterId, CancellationToken ct = default) { var chapterInfo = await context.Chapter .Where(c => c.Id == chapterId) .Join(context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new { ChapterNumber = chapter.MinNumber, VolumeNumber = volume.Name, VolumeId = volume.Id, chapter.IsSpecial, chapter.TitleName, volume.SeriesId, chapter.Pages, }) .Join(context.Series, data => data.SeriesId, series => series.Id, (data, series) => new { data.ChapterNumber, data.VolumeNumber, data.VolumeId, data.IsSpecial, data.SeriesId, data.Pages, data.TitleName, SeriesFormat = series.Format, SeriesName = series.Name, series.LibraryId, LibraryType = series.Library.Type }) .Select(data => new ChapterInfoDto() { ChapterNumber = data.ChapterNumber + string.Empty, VolumeNumber = data.VolumeNumber + string.Empty, VolumeId = data.VolumeId, IsSpecial = data.IsSpecial, SeriesId = data.SeriesId, SeriesFormat = data.SeriesFormat, SeriesName = data.SeriesName, LibraryId = data.LibraryId, Pages = data.Pages, ChapterTitle = data.TitleName, LibraryType = data.LibraryType }) .AsNoTracking() .AsSplitQuery() .SingleOrDefaultAsync(ct); return chapterInfo; } public Task GetChapterTotalPagesAsync(int chapterId, CancellationToken ct = default) { return context.Chapter .Where(c => c.Id == chapterId) .Select(c => c.Pages) .FirstOrDefaultAsync(ct); } public async Task GetChapterDtoAsync(int chapterId, int userId, CancellationToken ct = default) { var chapter = await context.Chapter .Includes(ChapterIncludes.Files | ChapterIncludes.People) .ProjectToWithProgress(mapper, userId) .AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == chapterId, ct); return chapter; } public async Task> GetChapterDtoByIdsAsync(IEnumerable chapterIds, int userId, CancellationToken ct = default) { var chapters = await context.Chapter .Where(c => chapterIds.Contains(c.Id)) .Includes(ChapterIncludes.Files | ChapterIncludes.People) .ProjectToWithProgress(mapper, userId) .AsSplitQuery() .ToListAsync(ct) ; return chapters; } public async Task GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files, CancellationToken ct = default) { var chapter = await context.Chapter .Includes(includes) .ProjectTo(mapper.ConfigurationProvider) .AsNoTracking() .AsSplitQuery() .SingleOrDefaultAsync(c => c.Id == chapterId, ct); return chapter; } /// /// Returns non-tracked files for a given chapterId /// /// /// /// public async Task> GetFilesForChapterAsync(int chapterId, CancellationToken ct = default) { return await context.MangaFile .Where(c => chapterId == c.ChapterId) .AsNoTracking() .ToListAsync(ct); } /// /// Returns a Chapter for an id. Includes linked s. /// /// /// /// /// public async Task GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files, CancellationToken ct = default) { return await context.Chapter .Includes(includes) .OrderBy(c => c.SortOrder) .FirstOrDefaultAsync(c => c.Id == chapterId, ct); } /// /// Returns Chapters for a volume id. /// /// /// /// /// public async Task> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None, CancellationToken ct = default) { return await context.Chapter .Where(c => c.VolumeId == volumeId) .Includes(includes) .OrderBy(c => c.SortOrder) .ToListAsync(ct); } /// /// Returns Chapters for a volume id with Progress /// /// /// /// /// public async Task> GetChapterDtosAsync(int volumeId, int userId, CancellationToken ct = default) { return await context.Chapter .Where(c => c.VolumeId == volumeId) .Includes(ChapterIncludes.Files | ChapterIncludes.People) .OrderBy(c => c.SortOrder) .ProjectToWithProgress(mapper, userId) .ToListAsync(ct); } /// /// Returns the cover image for a chapter id. /// /// /// /// public async Task GetChapterCoverImageAsync(int chapterId, CancellationToken ct = default) { return await context.Chapter .Where(c => c.Id == chapterId) .Select(c => c.CoverImage) .SingleOrDefaultAsync(ct); } public async Task> GetAllCoverImagesAsync(CancellationToken ct = default) { return (await context.Chapter .Select(c => c.CoverImage) .Where(t => !string.IsNullOrEmpty(t)) .ToListAsync(ct))!; } public async Task> GetAllChaptersWithCoversInDifferentEncoding(EncodeFormat format, CancellationToken ct = default) { var extension = format.GetExtension(); return await context.Chapter .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension)) .ToListAsync(ct); } /// /// Returns cover images for locked chapters /// /// public async Task> GetCoverImagesForLockedChaptersAsync(CancellationToken ct = default) { return (await context.Chapter .Where(c => c.CoverImageLocked) .Select(c => c.CoverImage) .Where(t => !string.IsNullOrEmpty(t)) .ToListAsync(ct))!; } /// /// Returns non-tracked files for a set of /// /// List of chapter Ids /// /// public async Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds, CancellationToken ct = default) { return await context.MangaFile .Where(c => chapterIds.Contains(c.ChapterId)) .AsNoTracking() .ToListAsync(ct); } public async Task GetFilesizeForChapterAsync(int chapterId, CancellationToken ct = default) { return await context.MangaFile .Where(c => c.ChapterId == chapterId) .SumAsync(c => c.Bytes, cancellationToken: ct); } public async Task> GetFilesizeForChaptersAsync(IList chapterIds, CancellationToken ct = default) { return await chapterIds.BatchToDictionaryAsync(50, batch => context.MangaFile .Where(f => batch.Contains(f.ChapterId)) .ToDictionaryAsync(f => f.ChapterId, f => f.Bytes, cancellationToken: ct)); } /// /// Includes Volumes /// /// /// /// public IQueryable GetChaptersForSeries(int seriesId, CancellationToken ct = default) { return context.Chapter .Where(c => c.Volume.SeriesId == seriesId) .OrderBy(c => c.SortOrder) .Include(c => c.Volume); } public async Task> GetAllChaptersForSeries(int seriesId, CancellationToken ct = default) { return await context.Chapter .Where(c => c.Volume.SeriesId == seriesId) .OrderBy(c => c.SortOrder) .Include(c => c.Volume) .Include(c => c.People) .ThenInclude(cp => cp.Person) .ToListAsync(ct); } public async Task GetAverageUserRating(int chapterId, int userId, CancellationToken ct = default) { // If there is a 0 or 1 rating and that rating is you, return 0 back var countOfRatingsThatAreUser = await context.AppUserChapterRating .Where(r => r.ChapterId == chapterId && r.HasBeenRated) .CountAsync(u => u.AppUserId == userId, ct); if (countOfRatingsThatAreUser == 1) { return 0; } var avg = await context.AppUserChapterRating .Where(r => r.ChapterId == chapterId && r.HasBeenRated) .AverageAsync(r => (int?) r.Rating, ct); return avg.HasValue ? (int) (avg.Value * 20) : 0; } public async Task> GetExternalChapterReviewDtos(int chapterId, CancellationToken ct = default) { return await context.Chapter .Where(c => c.Id == chapterId) .SelectMany(c => c.ExternalReviews) // Don't use ProjectTo, it fails to map int to float (??) .Select(r => mapper.Map(r)) .ToListAsync(ct); } public async Task> GetExternalChapterReview(int chapterId, CancellationToken ct = default) { return await context.Chapter .Where(c => c.Id == chapterId) .SelectMany(c => c.ExternalReviews) .ToListAsync(ct); } public async Task> GetExternalChapterRatingDtos(int chapterId, CancellationToken ct = default) { return await context.Chapter .Where(c => c.Id == chapterId) .SelectMany(c => c.ExternalRatings) .ProjectTo(mapper.ConfigurationProvider) .ToListAsync(ct); } public async Task> GetExternalChapterRatings(int chapterId, CancellationToken ct = default) { return await context.Chapter .Where(c => c.Id == chapterId) .SelectMany(c => c.ExternalRatings) .ToListAsync(ct); } public async Task GetCurrentlyReadingChapterAsync(int seriesId, int userId, CancellationToken ct = default) { var chapterWithProgress = await context.AppUserProgresses .Where(p => p.AppUserId == userId) .Join( context.Chapter .Include(c => c.Volume) .Include(c => c.Files), p => p.ChapterId, c => c.Id, (p, c) => new { Chapter = c, p.PagesRead } ) .Where(x => x.Chapter.Volume.SeriesId == seriesId) .Where(x => x.Chapter.Volume.Number != ParserConstants.LooseLeafVolumeNumber) .Where(x => x.PagesRead > 0 && x.PagesRead < x.Chapter.Pages) .OrderBy(x => x.Chapter.Volume.Number) .ThenBy(x => x.Chapter.SortOrder) .AsNoTracking() .FirstOrDefaultAsync(ct); if (chapterWithProgress == null) return null; // Map chapter to DTO var dto = mapper.Map(chapterWithProgress.Chapter); dto.PagesRead = chapterWithProgress.PagesRead; return dto; } public async Task GetFirstChapterForSeriesAsync(int seriesId, int userId, CancellationToken ct = default) { // Get the chapter entity with proper ordering return await context.Chapter .Include(c => c.Volume) .Include(c => c.Files) .Where(c => c.Volume.SeriesId == seriesId) .ApplyDefaultChapterOrdering() .AsNoTracking() .ProjectToWithProgress(mapper, userId) .FirstOrDefaultAsync(ct); } public async Task GetFirstChapterForVolumeAsync(int volumeId, int userId, CancellationToken ct = default) { // Get the chapter entity with proper ordering return await context.Chapter .Include(c => c.Volume) .Include(c => c.Files) .Where(c => c.Volume.Id == volumeId) .ApplyDefaultChapterOrdering() .AsNoTracking() .ProjectToWithProgress(mapper, userId) .FirstOrDefaultAsync(ct); } public async Task> GetChapterDtosAsync(IEnumerable chapterIds, int userId, CancellationToken ct = default) { var chapterIdList = chapterIds.ToList(); if (chapterIdList.Count == 0) return []; return await context.Chapter .Where(c => chapterIdList.Contains(c.Id)) .ProjectToWithProgress(mapper, userId) .ToListAsync(ct); } public async Task GetSeriesIdForChapter(int chapterId, CancellationToken ct = default) { return await context.Chapter .Where(chp => chp.Id == chapterId) .Select(chp => chp.Volume.SeriesId) .FirstOrDefaultAsync(ct); } }