mirror of
				https://github.com/Kareadita/Kavita.git
				synced 2025-11-04 03:27:05 -05:00 
			
		
		
		
	
		
			
				
	
	
		
			2015 lines
		
	
	
		
			84 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			2015 lines
		
	
	
		
			84 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System;
 | 
						|
using System.Collections.Generic;
 | 
						|
using System.Globalization;
 | 
						|
using System.Linq;
 | 
						|
using System.Text.RegularExpressions;
 | 
						|
using System.Threading.Tasks;
 | 
						|
using API.Data.ManualMigrations;
 | 
						|
using API.Data.Misc;
 | 
						|
using API.Data.Scanner;
 | 
						|
using API.DTOs;
 | 
						|
using API.DTOs.CollectionTags;
 | 
						|
using API.DTOs.Dashboard;
 | 
						|
using API.DTOs.Filtering;
 | 
						|
using API.DTOs.Filtering.v2;
 | 
						|
using API.DTOs.Metadata;
 | 
						|
using API.DTOs.Reader;
 | 
						|
using API.DTOs.ReadingLists;
 | 
						|
using API.DTOs.Search;
 | 
						|
using API.DTOs.SeriesDetail;
 | 
						|
using API.DTOs.Settings;
 | 
						|
using API.Entities;
 | 
						|
using API.Entities.Enums;
 | 
						|
using API.Entities.Metadata;
 | 
						|
using API.Extensions;
 | 
						|
using API.Extensions.QueryExtensions;
 | 
						|
using API.Extensions.QueryExtensions.Filtering;
 | 
						|
using API.Helpers;
 | 
						|
using API.Helpers.Converters;
 | 
						|
using API.Services;
 | 
						|
using API.Services.Tasks;
 | 
						|
using API.Services.Tasks.Scanner;
 | 
						|
using AutoMapper;
 | 
						|
using AutoMapper.QueryableExtensions;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using SQLite;
 | 
						|
 | 
						|
 | 
						|
namespace API.Data.Repositories;
 | 
						|
 | 
						|
[Flags]
 | 
						|
public enum SeriesIncludes
 | 
						|
{
 | 
						|
    None = 1,
 | 
						|
    Volumes = 2,
 | 
						|
    Metadata = 4,
 | 
						|
    Related = 8,
 | 
						|
    Library = 16,
 | 
						|
    Chapters = 32
 | 
						|
}
 | 
						|
 | 
						|
/// <summary>
 | 
						|
/// For complex queries, Library has certain restrictions where the library should not be included in results.
 | 
						|
/// This enum dictates which field to use for the lookup.
 | 
						|
/// </summary>
 | 
						|
public enum QueryContext
 | 
						|
{
 | 
						|
    None = 1,
 | 
						|
    Search = 2,
 | 
						|
    Recommended = 3,
 | 
						|
    Dashboard = 4,
 | 
						|
}
 | 
						|
 | 
						|
public interface ISeriesRepository
 | 
						|
{
 | 
						|
    void Add(Series series);
 | 
						|
    void Attach(Series series);
 | 
						|
    void Update(Series series);
 | 
						|
    void Remove(Series series);
 | 
						|
    void Remove(IEnumerable<Series> series);
 | 
						|
    Task<bool> DoesSeriesNameExistInLibrary(string name, int libraryId, MangaFormat format);
 | 
						|
    /// <summary>
 | 
						|
    /// Adds user information like progress, ratings, etc
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryId"></param>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <param name="userParams">Pagination info</param>
 | 
						|
    /// <param name="filter">Filtering/Sorting to apply</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter);
 | 
						|
    /// <summary>
 | 
						|
    /// Does not add user information like progress, ratings, etc.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <param name="isAdmin"></param>
 | 
						|
    /// <param name="libraryIds"></param>
 | 
						|
    /// <param name="searchQuery"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery);
 | 
						|
    Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None);
 | 
						|
    Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId);
 | 
						|
    Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
 | 
						|
    Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds);
 | 
						|
    Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds);
 | 
						|
    Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
 | 
						|
    /// <summary>
 | 
						|
    /// Used to add Progress/Rating information to series list.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <param name="series"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    Task AddSeriesModifiers(int userId, IList<SeriesDto> series);
 | 
						|
    Task<string?> GetSeriesCoverImageAsync(int seriesId);
 | 
						|
    Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter);
 | 
						|
    Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
 | 
						|
    Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter);
 | 
						|
    Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId);
 | 
						|
    Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
 | 
						|
    Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
 | 
						|
    Task<IEnumerable<SeriesDto>> GetSeriesDtoForIdsAsync(IEnumerable<int> seriesIds, int userId);
 | 
						|
    Task<IList<string>> GetAllCoverImagesAsync();
 | 
						|
    Task<IEnumerable<string>> GetLockedCoverImagesAsync();
 | 
						|
    Task<PagedList<Series>> GetFullSeriesForLibraryIdAsync(int libraryId, UserParams userParams);
 | 
						|
    Task<Series?> GetFullSeriesForSeriesIdAsync(int seriesId);
 | 
						|
    Task<Chunk> GetChunkInfo(int libraryId = 0);
 | 
						|
    Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
 | 
						|
    Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30);
 | 
						|
    Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
 | 
						|
    Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
 | 
						|
    Task<PagedList<SeriesDto>> GetQuickReads(int userId, int libraryId, UserParams userParams);
 | 
						|
    Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams);
 | 
						|
    Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams);
 | 
						|
    Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams);
 | 
						|
    Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams);
 | 
						|
    Task<SeriesDto?> GetSeriesForMangaFile(int mangaFileId, int userId);
 | 
						|
    Task<SeriesDto?> GetSeriesForChapter(int chapterId, int userId);
 | 
						|
    Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
 | 
						|
    Task<PagedList<SeriesDto>> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter);
 | 
						|
    Task<IList<Series>> GetWantToReadForUserAsync(int userId);
 | 
						|
    Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
 | 
						|
    Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
 | 
						|
    Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
 | 
						|
        int userId, SeriesIncludes includes = SeriesIncludes.None);
 | 
						|
    Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
 | 
						|
    Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
 | 
						|
    Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
 | 
						|
    Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
 | 
						|
    /// <summary>
 | 
						|
    /// This is only used for <see cref="MigrateUserProgressLibraryId"/>
 | 
						|
    /// </summary>
 | 
						|
    /// <returns></returns>
 | 
						|
    Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
 | 
						|
    Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds);
 | 
						|
    Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat, bool customOnly = true);
 | 
						|
    Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIdsForUser(int userId, IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl);
 | 
						|
    Task<int> GetAverageUserRating(int seriesId, int userId);
 | 
						|
    Task RemoveFromOnDeck(int seriesId, int userId);
 | 
						|
    Task ClearOnDeckRemoval(int seriesId, int userId);
 | 
						|
    Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
 | 
						|
}
 | 
						|
 | 
						|
public class SeriesRepository : ISeriesRepository
 | 
						|
{
 | 
						|
    private readonly DataContext _context;
 | 
						|
    private readonly IMapper _mapper;
 | 
						|
 | 
						|
    private readonly Regex _yearRegex = new Regex(@"\d{4}", RegexOptions.Compiled,
 | 
						|
        Services.Tasks.Scanner.Parser.Parser.RegexTimeout);
 | 
						|
 | 
						|
    public SeriesRepository(DataContext context, IMapper mapper)
 | 
						|
    {
 | 
						|
        _context = context;
 | 
						|
        _mapper = mapper;
 | 
						|
    }
 | 
						|
 | 
						|
    public void Add(Series series)
 | 
						|
    {
 | 
						|
        _context.Series.Add(series);
 | 
						|
    }
 | 
						|
 | 
						|
    public void Attach(Series series)
 | 
						|
    {
 | 
						|
        _context.Series.Attach(series);
 | 
						|
    }
 | 
						|
 | 
						|
    public void Update(Series series)
 | 
						|
    {
 | 
						|
        _context.Entry(series).State = EntityState.Modified;
 | 
						|
    }
 | 
						|
 | 
						|
    public void Remove(Series series)
 | 
						|
    {
 | 
						|
        _context.Series.Remove(series);
 | 
						|
    }
 | 
						|
 | 
						|
    public void Remove(IEnumerable<Series> series)
 | 
						|
    {
 | 
						|
        _context.Series.RemoveRange(series);
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns if a series name and format exists already in a library
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="name">Name of series</param>
 | 
						|
    /// <param name="libraryId"></param>
 | 
						|
    /// <param name="format">Format of series</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<bool> DoesSeriesNameExistInLibrary(string name, int libraryId, MangaFormat format)
 | 
						|
    {
 | 
						|
        return await _context.Series
 | 
						|
            .AsNoTracking()
 | 
						|
            .Where(s => s.LibraryId == libraryId && s.Name.Equals(name) && s.Format == format)
 | 
						|
            .AnyAsync();
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    public async Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None)
 | 
						|
    {
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => s.LibraryId == libraryId)
 | 
						|
            .Includes(includes)
 | 
						|
            .OrderBy(s => s.SortName.ToLower())
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Used for <see cref="ScannerService"/> to
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryId"></param>
 | 
						|
    /// <param name="userParams"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<PagedList<Series>> GetFullSeriesForLibraryIdAsync(int libraryId, UserParams userParams)
 | 
						|
    {
 | 
						|
        #nullable  disable
 | 
						|
        var query = _context.Series
 | 
						|
            .Where(s => s.LibraryId == libraryId)
 | 
						|
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.CollectionTags)
 | 
						|
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.People)
 | 
						|
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.Genres)
 | 
						|
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.Tags)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(cm => cm.People)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(c => c.Genres)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(c => c.Tags)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)!
 | 
						|
            .ThenInclude(v => v.Chapters)!
 | 
						|
            .ThenInclude(c => c.Files)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .OrderBy(s => s.SortName.ToLower());
 | 
						|
#nullable  enable
 | 
						|
 | 
						|
        return await PagedList<Series>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// This is a heavy call. Returns all entities down to Files and Library and Series Metadata.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<Series?> GetFullSeriesForSeriesIdAsync(int seriesId)
 | 
						|
    {
 | 
						|
        #nullable  disable
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => s.Id == seriesId)
 | 
						|
            .Include(s => s.Relations)
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.People)
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.Genres)
 | 
						|
            .Include(s => s.Library)
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(cm => cm.People)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(c => c.Tags)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(c => c.Genres)
 | 
						|
 | 
						|
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.Tags)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(c => c.Files)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
        #nullable  enable
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Gets all series
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryId">Restricts to just one library</param>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <param name="userParams"></param>
 | 
						|
    /// <param name="filter"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    [Obsolete("Use GetSeriesDtoForLibraryIdAsync")]
 | 
						|
    public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter)
 | 
						|
    {
 | 
						|
        var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.None);
 | 
						|
 | 
						|
        var retSeries = query
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<List<int>> GetUserLibrariesForFilteredQuery(int libraryId, int userId, QueryContext queryContext)
 | 
						|
    {
 | 
						|
        if (libraryId == 0)
 | 
						|
        {
 | 
						|
            return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync();
 | 
						|
        }
 | 
						|
 | 
						|
        return new List<int>()
 | 
						|
        {
 | 
						|
            libraryId
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery)
 | 
						|
    {
 | 
						|
        const int maxRecords = 15;
 | 
						|
        var result = new SearchResultGroupDto();
 | 
						|
        var searchQueryNormalized = searchQuery.ToNormalized();
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
 | 
						|
        var seriesIds = _context.Series
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .Select(s => s.Id)
 | 
						|
            .ToList();
 | 
						|
 | 
						|
        result.Libraries = await _context.Library
 | 
						|
            .Where(l => libraryIds.Contains(l.Id))
 | 
						|
            .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
 | 
						|
            .IsRestricted(QueryContext.Search)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Take(maxRecords)
 | 
						|
            .OrderBy(l => l.Name.ToLower())
 | 
						|
            .ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var justYear = _yearRegex.Match(searchQuery).Value;
 | 
						|
        var hasYearInQuery = !string.IsNullOrEmpty(justYear);
 | 
						|
        var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
 | 
						|
 | 
						|
        result.Series = _context.Series
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
 | 
						|
                         || (s.OriginalName != null && EF.Functions.Like(s.OriginalName, $"%{searchQuery}%"))
 | 
						|
                         || (s.LocalizedName != null && EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
 | 
						|
                         || (EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%"))
 | 
						|
                         || (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .Include(s => s.Library)
 | 
						|
            .AsNoTracking()
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Take(maxRecords)
 | 
						|
            .OrderBy(s => s.SortName!.ToLower())
 | 
						|
            .ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsEnumerable();
 | 
						|
 | 
						|
        result.Bookmarks = (await _context.AppUserBookmark
 | 
						|
            .Join(
 | 
						|
                _context.Series,
 | 
						|
                bookmark => bookmark.SeriesId,
 | 
						|
                series => series.Id,
 | 
						|
                (bookmark, series) => new {Bookmark = bookmark, Series = series}
 | 
						|
            )
 | 
						|
            .Where(joined => joined.Bookmark.AppUserId == userId &&
 | 
						|
                             (EF.Functions.Like(joined.Series.Name, $"%{searchQuery}%") ||
 | 
						|
                              (joined.Series.OriginalName != null &&
 | 
						|
                               EF.Functions.Like(joined.Series.OriginalName, $"%{searchQuery}%")) ||
 | 
						|
                              (joined.Series.LocalizedName != null &&
 | 
						|
                               EF.Functions.Like(joined.Series.LocalizedName, $"%{searchQuery}%"))))
 | 
						|
            .OrderBy(joined => joined.Series.Name)
 | 
						|
            .Take(maxRecords)
 | 
						|
            .Select(joined => new BookmarkSearchResultDto()
 | 
						|
            {
 | 
						|
                SeriesName = joined.Series.Name,
 | 
						|
                LocalizedSeriesName = joined.Series.LocalizedName,
 | 
						|
                LibraryId = joined.Series.LibraryId,
 | 
						|
                SeriesId = joined.Bookmark.SeriesId,
 | 
						|
                ChapterId = joined.Bookmark.ChapterId,
 | 
						|
                VolumeId = joined.Bookmark.VolumeId
 | 
						|
            })
 | 
						|
            .ToListAsync()).DistinctBy(s => s.SeriesId);
 | 
						|
 | 
						|
 | 
						|
        result.ReadingLists = await _context.ReadingList
 | 
						|
            .Where(rl => rl.AppUserId == userId || rl.Promoted)
 | 
						|
            .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Take(maxRecords)
 | 
						|
            .OrderBy(r => r.NormalizedTitle)
 | 
						|
            .ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        result.Collections =  await _context.CollectionTag
 | 
						|
            .Where(c => (EF.Functions.Like(c.Title, $"%{searchQuery}%"))
 | 
						|
                                    || (EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%")))
 | 
						|
            .Where(c => c.Promoted || isAdmin)
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .OrderBy(s => s.NormalizedTitle)
 | 
						|
            .AsNoTracking()
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Take(maxRecords)
 | 
						|
            .OrderBy(c => c.NormalizedTitle)
 | 
						|
            .ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        result.Persons = await _context.SeriesMetadata
 | 
						|
            .Where(sm => seriesIds.Contains(sm.SeriesId))
 | 
						|
            .SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Distinct()
 | 
						|
            .Take(maxRecords)
 | 
						|
            .OrderBy(p => p.NormalizedName)
 | 
						|
            .ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        result.Genres = await _context.SeriesMetadata
 | 
						|
            .Where(sm => seriesIds.Contains(sm.SeriesId))
 | 
						|
            .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Distinct()
 | 
						|
            .Take(maxRecords)
 | 
						|
            .OrderBy(t => t.NormalizedTitle)
 | 
						|
            .ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        result.Tags = await _context.SeriesMetadata
 | 
						|
            .Where(sm => seriesIds.Contains(sm.SeriesId))
 | 
						|
            .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Distinct()
 | 
						|
            .Take(maxRecords)
 | 
						|
            .OrderBy(t => t.NormalizedTitle)
 | 
						|
            .ProjectTo<TagDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var fileIds = _context.Series
 | 
						|
            .Where(s => seriesIds.Contains(s.Id))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .SelectMany(s => s.Volumes)
 | 
						|
            .SelectMany(v => v.Chapters)
 | 
						|
            .SelectMany(c => c.Files.Select(f => f.Id));
 | 
						|
 | 
						|
        result.Files = await _context.MangaFile
 | 
						|
            .Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Take(maxRecords)
 | 
						|
            .OrderBy(f => f.FilePath)
 | 
						|
            .ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
 | 
						|
        result.Chapters = await _context.Chapter
 | 
						|
            .Include(c => c.Files)
 | 
						|
            .Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%")
 | 
						|
                        || EF.Functions.Like(c.ISBN, $"%{searchQuery}%")
 | 
						|
                )
 | 
						|
            .Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Take(maxRecords)
 | 
						|
            .OrderBy(c => c.TitleName)
 | 
						|
            .ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        return result;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Includes Progress for the user
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId)
 | 
						|
    {
 | 
						|
        var series = await _context.Series.Where(x => x.Id == seriesId)
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
 | 
						|
        if (series == null) return null;
 | 
						|
 | 
						|
        var seriesList = new List<SeriesDto>() {series};
 | 
						|
        await AddSeriesModifiers(userId, seriesList);
 | 
						|
 | 
						|
        return seriesList[0];
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns Volumes, Metadata (Incl Genres and People), and Collection Tags
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <param name="includes"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata)
 | 
						|
    {
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => s.Id == seriesId)
 | 
						|
            .Includes(includes)
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<Series?> GetSeriesByIdForUserAsync(int seriesId, int userId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata)
 | 
						|
    {
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => s.Id == seriesId)
 | 
						|
            .Includes(includes)
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns Volumes, Metadata, and Collection Tags
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesIds"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds)
 | 
						|
    {
 | 
						|
        return await _context.Series
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.CollectionTags)
 | 
						|
            .Include(s => s.Relations)
 | 
						|
            .Where(s => seriesIds.Contains(s.Id))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds)
 | 
						|
    {
 | 
						|
        var volumes = await _context.Volume
 | 
						|
            .Where(v => seriesIds.Contains(v.SeriesId))
 | 
						|
            .Include(v => v.Chapters)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        IList<int> chapterIds = new List<int>();
 | 
						|
        foreach (var v in volumes)
 | 
						|
        {
 | 
						|
            foreach (var c in v.Chapters)
 | 
						|
            {
 | 
						|
                chapterIds.Add(c.Id);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return chapterIds.ToArray();
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// This returns a dictionary mapping seriesId -> list of chapters back for each series id passed
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesIds"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds)
 | 
						|
    {
 | 
						|
        var volumes = await _context.Volume
 | 
						|
            .Where(v => seriesIds.Contains(v.SeriesId))
 | 
						|
            .Include(v => v.Chapters)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var seriesChapters = new Dictionary<int, IList<int>>();
 | 
						|
        foreach (var v in volumes)
 | 
						|
        {
 | 
						|
            foreach (var c in v.Chapters)
 | 
						|
            {
 | 
						|
                if (!seriesChapters.ContainsKey(v.SeriesId))
 | 
						|
                {
 | 
						|
                    var list = new List<int>();
 | 
						|
                    seriesChapters.Add(v.SeriesId, list);
 | 
						|
                }
 | 
						|
                seriesChapters[v.SeriesId].Add(c.Id);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return seriesChapters;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync()
 | 
						|
    {
 | 
						|
        var seriesChapters = new Dictionary<int, int>();
 | 
						|
        var series = await _context.Series.Select(s => new
 | 
						|
        {
 | 
						|
            Id = s.Id, LibraryId = s.LibraryId
 | 
						|
        }).ToListAsync();
 | 
						|
        foreach (var s in series)
 | 
						|
        {
 | 
						|
            seriesChapters.Add(s.Id, s.LibraryId);
 | 
						|
        }
 | 
						|
 | 
						|
        return seriesChapters;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds)
 | 
						|
    {
 | 
						|
        return await _context.SeriesMetadata
 | 
						|
            .Where(metadata => seriesIds.Contains(metadata.SeriesId))
 | 
						|
            .Include(m => m.Genres.OrderBy(g => g.NormalizedTitle))
 | 
						|
            .Include(m => m.Tags.OrderBy(g => g.NormalizedTitle))
 | 
						|
            .Include(m => m.People)
 | 
						|
            .AsNoTracking()
 | 
						|
            .ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns custom images only
 | 
						|
    /// </summary>
 | 
						|
    /// <remarks>If customOnly, this will not include any volumes/chapters</remarks>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat,
 | 
						|
        bool customOnly = true)
 | 
						|
    {
 | 
						|
        var extension = encodeFormat.GetExtension();
 | 
						|
        var prefix = ImageService.GetSeriesFormat(0).Replace("0", string.Empty);
 | 
						|
        var query = _context.Series
 | 
						|
            .Where(c => !string.IsNullOrEmpty(c.CoverImage)
 | 
						|
                        && !c.CoverImage.EndsWith(extension)
 | 
						|
                        && (!customOnly || c.CoverImage.StartsWith(prefix)))
 | 
						|
            .AsSplitQuery();
 | 
						|
 | 
						|
        if (!customOnly)
 | 
						|
        {
 | 
						|
            query = query.Include(s => s.Volumes)
 | 
						|
                .ThenInclude(v => v.Chapters);
 | 
						|
        }
 | 
						|
 | 
						|
        return await query.ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto)
 | 
						|
    {
 | 
						|
        var query = await CreateFilteredSearchQueryableV2(userId, filterDto, QueryContext.None);
 | 
						|
 | 
						|
        var retSeries = query
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
 | 
						|
    {
 | 
						|
        var userProgress = await _context.AppUserProgresses
 | 
						|
            .Where(p => p.AppUserId == userId && series.Select(s => s.Id).Contains(p.SeriesId))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var userRatings = await _context.AppUserRating
 | 
						|
            .Where(r => r.AppUserId == userId && series.Select(s => s.Id).Contains(r.SeriesId))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        foreach (var s in series)
 | 
						|
        {
 | 
						|
            s.PagesRead = userProgress.Where(p => p.SeriesId == s.Id).Sum(p => p.PagesRead);
 | 
						|
            var rating = userRatings.SingleOrDefault(r => r.SeriesId == s.Id);
 | 
						|
            if (rating != null)
 | 
						|
            {
 | 
						|
                s.UserRating = rating.Rating;
 | 
						|
                s.HasUserRated = rating.HasBeenRated;
 | 
						|
            }
 | 
						|
 | 
						|
            if (userProgress.Count > 0)
 | 
						|
            {
 | 
						|
                s.LatestReadDate = userProgress.Max(p => p.LastModified);
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<string?> GetSeriesCoverImageAsync(int seriesId)
 | 
						|
    {
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => s.Id == seriesId)
 | 
						|
            .Select(s => s.CoverImage)
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns a list of Series that were added, ordered by Created desc
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
 | 
						|
    /// <param name="userParams">Contains pagination information</param>
 | 
						|
    /// <param name="filter">Optional filter on query</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    [Obsolete("Use GetRecentlyAddedV2")]
 | 
						|
    public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter)
 | 
						|
    {
 | 
						|
        var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.Dashboard);
 | 
						|
 | 
						|
        var retSeries = query
 | 
						|
            .OrderByDescending(s => s.Created)
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter)
 | 
						|
    {
 | 
						|
        var query = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.Dashboard);
 | 
						|
 | 
						|
        var retSeries = query
 | 
						|
            .OrderByDescending(s => s.Created)
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
 | 
						|
        out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
 | 
						|
        out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter,
 | 
						|
        out bool hasLanguageFilter, out bool hasPublicationFilter, out bool hasSeriesNameFilter, out bool hasReleaseYearMinFilter, out bool hasReleaseYearMaxFilter)
 | 
						|
    {
 | 
						|
        var formats = filter.GetSqlFilter();
 | 
						|
 | 
						|
        if (filter.Libraries.Count > 0)
 | 
						|
        {
 | 
						|
            userLibraries = userLibraries.Where(l => filter.Libraries.Contains(l)).ToList();
 | 
						|
        }
 | 
						|
        else if (libraryId > 0)
 | 
						|
        {
 | 
						|
            userLibraries = userLibraries.Where(l => l == libraryId).ToList();
 | 
						|
        }
 | 
						|
 | 
						|
        allPeopleIds = new List<int>();
 | 
						|
        allPeopleIds.AddRange(filter.Writers);
 | 
						|
        allPeopleIds.AddRange(filter.Character);
 | 
						|
        allPeopleIds.AddRange(filter.Colorist);
 | 
						|
        allPeopleIds.AddRange(filter.Editor);
 | 
						|
        allPeopleIds.AddRange(filter.Inker);
 | 
						|
        allPeopleIds.AddRange(filter.Letterer);
 | 
						|
        allPeopleIds.AddRange(filter.Penciller);
 | 
						|
        allPeopleIds.AddRange(filter.Publisher);
 | 
						|
        allPeopleIds.AddRange(filter.CoverArtist);
 | 
						|
        allPeopleIds.AddRange(filter.Translators);
 | 
						|
 | 
						|
        hasPeopleFilter = allPeopleIds.Count > 0;
 | 
						|
        hasGenresFilter = filter.Genres.Count > 0;
 | 
						|
        hasCollectionTagFilter = filter.CollectionTags.Count > 0;
 | 
						|
        hasRatingFilter = filter.Rating > 0;
 | 
						|
        hasProgressFilter = !filter.ReadStatus.Read || !filter.ReadStatus.InProgress || !filter.ReadStatus.NotRead;
 | 
						|
        hasAgeRating = filter.AgeRating.Count > 0;
 | 
						|
        hasTagsFilter = filter.Tags.Count > 0;
 | 
						|
        hasLanguageFilter = filter.Languages.Count > 0;
 | 
						|
        hasPublicationFilter = filter.PublicationStatus.Count > 0;
 | 
						|
 | 
						|
        hasReleaseYearMinFilter = filter.ReleaseYearRange != null && filter.ReleaseYearRange.Min != 0;
 | 
						|
        hasReleaseYearMaxFilter = filter.ReleaseYearRange != null && filter.ReleaseYearRange.Max != 0;
 | 
						|
 | 
						|
 | 
						|
        bool ProgressComparison(int pagesRead, int totalPages)
 | 
						|
        {
 | 
						|
            var result = false;
 | 
						|
            if (filter.ReadStatus.NotRead)
 | 
						|
            {
 | 
						|
                result = (pagesRead == 0);
 | 
						|
            }
 | 
						|
 | 
						|
            if (filter.ReadStatus.Read)
 | 
						|
            {
 | 
						|
                result = result || (pagesRead == totalPages);
 | 
						|
            }
 | 
						|
 | 
						|
            if (filter.ReadStatus.InProgress)
 | 
						|
            {
 | 
						|
                result = result || (pagesRead > 0 && pagesRead < totalPages);
 | 
						|
            }
 | 
						|
 | 
						|
            return result;
 | 
						|
        }
 | 
						|
 | 
						|
        seriesIds = new List<int>();
 | 
						|
        if (hasProgressFilter)
 | 
						|
        {
 | 
						|
            seriesIds = _context.Series
 | 
						|
                .Include(s => s.Progress)
 | 
						|
                .Select(s => new
 | 
						|
                {
 | 
						|
                    Series = s,
 | 
						|
                    PagesRead = s.Progress.Where(p => p.AppUserId == userId).Sum(p => p.PagesRead),
 | 
						|
                })
 | 
						|
                .AsEnumerable()
 | 
						|
                .Where(s => ProgressComparison(s.PagesRead, s.Series.Pages))
 | 
						|
                .Select(s => s.Series.Id)
 | 
						|
                .ToList();
 | 
						|
        }
 | 
						|
 | 
						|
        hasSeriesNameFilter = !string.IsNullOrEmpty(filter.SeriesNameQuery);
 | 
						|
 | 
						|
        return formats;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns Series that the user has some partial progress on. Sorts based on activity. Sort first by User progress, then
 | 
						|
    /// by when chapters have been added to series. Restricts progress in the past 30 days and chapters being added to last 7.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
 | 
						|
    /// <param name="userParams">Pagination information</param>
 | 
						|
    /// <param name="filter">Optional (default null) filter on query</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter)
 | 
						|
    {
 | 
						|
        var settings = await _context.ServerSetting
 | 
						|
            .Select(x => x)
 | 
						|
            .AsNoTracking()
 | 
						|
            .ToListAsync();
 | 
						|
        var serverSettings = _mapper.Map<ServerSettingDto>(settings);
 | 
						|
 | 
						|
        var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(serverSettings.OnDeckProgressDays);
 | 
						|
        var cutoffLastAddedPoint = DateTime.Now - TimeSpan.FromDays(serverSettings.OnDeckUpdateDays);
 | 
						|
 | 
						|
        var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard)
 | 
						|
            .Where(id => libraryId == 0 || id == libraryId);
 | 
						|
        var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
 | 
						|
 | 
						|
        // Don't allow any series the user has explicitly removed
 | 
						|
        var onDeckRemovals = _context.AppUserOnDeckRemoval
 | 
						|
            .Where(d => d.AppUserId == userId)
 | 
						|
            .Select(d => d.SeriesId)
 | 
						|
            .AsEnumerable();
 | 
						|
 | 
						|
        var query = _context.Series
 | 
						|
            .Where(s => usersSeriesIds.Contains(s.Id))
 | 
						|
            .Where(s => !onDeckRemovals.Contains(s.Id))
 | 
						|
            .Select(s => new
 | 
						|
            {
 | 
						|
                Series = s,
 | 
						|
                PagesRead = _context.AppUserProgresses.Where(p => p.SeriesId == s.Id && p.AppUserId == userId)
 | 
						|
                    .Sum(s1 => s1.PagesRead),
 | 
						|
                LatestReadDate = _context.AppUserProgresses
 | 
						|
                    .Where(p => p.SeriesId == s.Id && p.AppUserId == userId)
 | 
						|
                    .Max(p => p.LastModified),
 | 
						|
                s.LastChapterAdded,
 | 
						|
            })
 | 
						|
            .Where(s => s.PagesRead > 0
 | 
						|
                        && s.PagesRead < s.Series.Pages)
 | 
						|
            .Where(d => d.LatestReadDate >= cutoffProgressPoint || d.LastChapterAdded >= cutoffLastAddedPoint)
 | 
						|
                .OrderByDescending(s => s.LatestReadDate)
 | 
						|
            .ThenByDescending(s => s.LastChapterAdded)
 | 
						|
            .Select(s => s.Series)
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, QueryContext queryContext)
 | 
						|
    {
 | 
						|
        // NOTE: Why do we even have libraryId when the filter has the actual libraryIds?
 | 
						|
        var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, queryContext);
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
        var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
 | 
						|
            .Select(u => u.CollapseSeriesRelationships)
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
 | 
						|
        var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
 | 
						|
            out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
 | 
						|
            out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
 | 
						|
            out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter,
 | 
						|
            out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter);
 | 
						|
 | 
						|
        var query = _context.Series
 | 
						|
            .AsNoTracking()
 | 
						|
            // This new style can handle any filterComparision coming from the user
 | 
						|
            .HasLanguage(hasLanguageFilter, FilterComparison.Contains, filter.Languages)
 | 
						|
            .HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max)
 | 
						|
            .HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min)
 | 
						|
            .HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery)
 | 
						|
            .HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating, userId)
 | 
						|
            .HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
 | 
						|
            .HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
 | 
						|
            .HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
 | 
						|
            .HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags)
 | 
						|
            .HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
 | 
						|
            .HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
 | 
						|
            .HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
 | 
						|
            .HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds)
 | 
						|
 | 
						|
            .WhereIf(onlyParentSeries,
 | 
						|
                s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel))
 | 
						|
            .Where(s => userLibraries.Contains(s.LibraryId));
 | 
						|
 | 
						|
        if (filter.ReadStatus.InProgress)
 | 
						|
        {
 | 
						|
            query = query.HasReadingProgress(hasProgressFilter, FilterComparison.GreaterThan,
 | 
						|
                0, userId)
 | 
						|
                .HasReadingProgress(hasProgressFilter, FilterComparison.LessThan,
 | 
						|
                    100, userId);
 | 
						|
        } else if (filter.ReadStatus.Read)
 | 
						|
        {
 | 
						|
            query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal,
 | 
						|
                100, userId);
 | 
						|
        }
 | 
						|
        else if (filter.ReadStatus.NotRead)
 | 
						|
        {
 | 
						|
            query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal,
 | 
						|
                0, userId);
 | 
						|
        }
 | 
						|
 | 
						|
        if (userRating.AgeRating != AgeRating.NotApplicable)
 | 
						|
        {
 | 
						|
             // this if statement is included in the extension
 | 
						|
            query = query.RestrictAgainstAgeRestriction(userRating);
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        // If no sort options, default to using SortName
 | 
						|
        filter.SortOptions ??= new SortOptions()
 | 
						|
        {
 | 
						|
            IsAscending = true,
 | 
						|
            SortField = SortField.SortName
 | 
						|
        };
 | 
						|
 | 
						|
        query = filter.SortOptions.SortField switch
 | 
						|
        {
 | 
						|
            SortField.SortName => query.DoOrderBy(s => s.SortName.ToLower(), filter.SortOptions),
 | 
						|
            SortField.CreatedDate => query.DoOrderBy(s => s.Created, filter.SortOptions),
 | 
						|
            SortField.LastModifiedDate => query.DoOrderBy(s => s.LastModified, filter.SortOptions),
 | 
						|
            SortField.LastChapterAdded => query.DoOrderBy(s => s.LastChapterAdded, filter.SortOptions),
 | 
						|
            SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, filter.SortOptions),
 | 
						|
            SortField.ReleaseYear => query.DoOrderBy(s => s.Metadata.ReleaseYear, filter.SortOptions),
 | 
						|
            SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max(), filter.SortOptions),
 | 
						|
            _ => query
 | 
						|
        };
 | 
						|
 | 
						|
        return query.AsSplitQuery();
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<IQueryable<Series>> CreateFilteredSearchQueryableV2(int userId, FilterV2Dto filter, QueryContext queryContext, IQueryable<Series>? query = null)
 | 
						|
    {
 | 
						|
        var userLibraries = await GetUserLibrariesForFilteredQuery(0, userId, queryContext);
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
        var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
 | 
						|
            .Select(u => u.CollapseSeriesRelationships)
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
 | 
						|
        query ??= _context.Series
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        // When the user has no access, just return instantly
 | 
						|
        if (userLibraries.Count == 0)
 | 
						|
        {
 | 
						|
            return query.Where(s => false);
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
 | 
						|
        // First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here
 | 
						|
        query = ApplyLibraryFilter(filter, query);
 | 
						|
 | 
						|
        query = ApplyWantToReadFilter(filter, query, userId);
 | 
						|
 | 
						|
 | 
						|
        query = BuildFilterQuery(userId, filter, query);
 | 
						|
 | 
						|
 | 
						|
        query = query
 | 
						|
            .WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId))
 | 
						|
            .WhereIf(onlyParentSeries, s =>
 | 
						|
                s.RelationOf.Count == 0 ||
 | 
						|
                s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating);
 | 
						|
 | 
						|
 | 
						|
        return ApplyLimit(query
 | 
						|
            .Sort(userId, filter.SortOptions)
 | 
						|
            .AsSplitQuery(), filter.LimitTo);
 | 
						|
    }
 | 
						|
 | 
						|
    private IQueryable<Series> ApplyWantToReadFilter(FilterV2Dto filter, IQueryable<Series> query, int userId)
 | 
						|
    {
 | 
						|
        var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
 | 
						|
        if (wantToReadStmt == null) return query;
 | 
						|
 | 
						|
        var seriesIds = _context.AppUser.Where(u => u.Id == userId).SelectMany(u => u.WantToRead).Select(s => s.Id);
 | 
						|
        if (bool.Parse(wantToReadStmt.Value))
 | 
						|
        {
 | 
						|
            query = query.Where(s => seriesIds.Contains(s.Id));
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            query = query.Where(s => !seriesIds.Contains(s.Id));
 | 
						|
        }
 | 
						|
 | 
						|
        return query;
 | 
						|
    }
 | 
						|
 | 
						|
    private static IQueryable<Series> ApplyLibraryFilter(FilterV2Dto filter, IQueryable<Series> query)
 | 
						|
    {
 | 
						|
        var filterIncludeLibs = new List<int>();
 | 
						|
        var filterExcludeLibs = new List<int>();
 | 
						|
        if (filter.Statements != null)
 | 
						|
        {
 | 
						|
            foreach (var stmt in filter.Statements.Where(stmt => stmt.Field == FilterField.Libraries))
 | 
						|
            {
 | 
						|
                var libIds = stmt.Value.Split(',').Select(int.Parse);
 | 
						|
                if (stmt.Comparison is FilterComparison.Equal or FilterComparison.Contains)
 | 
						|
                {
 | 
						|
 | 
						|
                    filterIncludeLibs.AddRange(libIds);
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    filterExcludeLibs.AddRange(libIds);
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            // Remove as filterLibs now has everything
 | 
						|
            filter.Statements = filter.Statements.Where(stmt => stmt.Field != FilterField.Libraries).ToList();
 | 
						|
        }
 | 
						|
 | 
						|
        // We now have a list of libraries the user wants it restricted to and libraries the user doesn't want in the list
 | 
						|
        // We need to check what the filer combo is to see how to next approach
 | 
						|
 | 
						|
        if (filter.Combination == FilterCombination.And)
 | 
						|
        {
 | 
						|
            // If the filter combo is AND, then we need 2 different queries
 | 
						|
            query = query
 | 
						|
                .WhereIf(filterIncludeLibs.Count > 0, s => filterIncludeLibs.Contains(s.LibraryId))
 | 
						|
                .WhereIf(filterExcludeLibs.Count > 0, s => !filterExcludeLibs.Contains(s.LibraryId));
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            // This is an OR statement. In that case we can just remove the filterExcludes
 | 
						|
            query = query.WhereIf(filterIncludeLibs.Count > 0, s => filterIncludeLibs.Contains(s.LibraryId));
 | 
						|
        }
 | 
						|
 | 
						|
        return query;
 | 
						|
    }
 | 
						|
 | 
						|
    private static IQueryable<Series> BuildFilterQuery(int userId, FilterV2Dto filterDto, IQueryable<Series> query)
 | 
						|
    {
 | 
						|
        if (filterDto.Statements == null || !filterDto.Statements.Any()) return query;
 | 
						|
 | 
						|
 | 
						|
        var queries = filterDto.Statements
 | 
						|
            .Select(statement => BuildFilterGroup(userId, statement, query))
 | 
						|
            .ToList();
 | 
						|
 | 
						|
        return filterDto.Combination == FilterCombination.And
 | 
						|
            ? queries.Aggregate((q1, q2) => q1.Intersect(q2))
 | 
						|
            : queries.Aggregate((q1, q2) => q1.Union(q2));
 | 
						|
    }
 | 
						|
 | 
						|
    private static IQueryable<Series> ApplyLimit(IQueryable<Series> query, int limit)
 | 
						|
    {
 | 
						|
        return limit <= 0 ? query : query.Take(limit);
 | 
						|
    }
 | 
						|
 | 
						|
    private static IQueryable<Series> BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable<Series> query)
 | 
						|
    {
 | 
						|
        var value = FilterFieldValueConverter.ConvertValue(statement.Field, statement.Value);
 | 
						|
        return statement.Field switch
 | 
						|
        {
 | 
						|
            FilterField.Summary => query.HasSummary(true, statement.Comparison, (string) value),
 | 
						|
            FilterField.SeriesName => query.HasName(true, statement.Comparison, (string) value),
 | 
						|
            FilterField.Path => query.HasPath(true, statement.Comparison, (string) value),
 | 
						|
            FilterField.FilePath => query.HasFilePath(true, statement.Comparison, (string) value),
 | 
						|
            FilterField.PublicationStatus => query.HasPublicationStatus(true, statement.Comparison,
 | 
						|
                (IList<PublicationStatus>) value),
 | 
						|
            FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList<string>) value),
 | 
						|
            FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
 | 
						|
            FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
 | 
						|
            FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
 | 
						|
            FilterField.Libraries =>
 | 
						|
                // This is handled in the code before this as it's handled in a more general, combined manner
 | 
						|
                query,
 | 
						|
            FilterField.WantToRead =>
 | 
						|
                // This is handled in the higher level of code as it's more general
 | 
						|
                query,
 | 
						|
            FilterField.ReadProgress => query.HasReadingProgress(true, statement.Comparison, (float) value, userId),
 | 
						|
            FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList<MangaFormat>) value),
 | 
						|
            FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
 | 
						|
            FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value),
 | 
						|
            FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId),
 | 
						|
            _ => throw new ArgumentOutOfRangeException()
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable<Series> sQuery)
 | 
						|
    {
 | 
						|
        var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, QueryContext.Search);
 | 
						|
        var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
 | 
						|
            out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
 | 
						|
            out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
 | 
						|
            out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter,
 | 
						|
            out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter);
 | 
						|
 | 
						|
        var query = sQuery
 | 
						|
            .WhereIf(hasGenresFilter, s => s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id)))
 | 
						|
            .WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id)))
 | 
						|
            .WhereIf(hasCollectionTagFilter,
 | 
						|
                s => s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
 | 
						|
            .WhereIf(hasRatingFilter, s => s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId))
 | 
						|
            .WhereIf(hasProgressFilter, s => seriesIds.Contains(s.Id))
 | 
						|
            .WhereIf(hasAgeRating, s => filter.AgeRating.Contains(s.Metadata.AgeRating))
 | 
						|
            .WhereIf(hasTagsFilter, s => s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
 | 
						|
            .WhereIf(hasLanguageFilter, s => filter.Languages.Contains(s.Metadata.Language))
 | 
						|
            .WhereIf(hasReleaseYearMinFilter, s => s.Metadata.ReleaseYear >= filter.ReleaseYearRange!.Min)
 | 
						|
            .WhereIf(hasReleaseYearMaxFilter, s => s.Metadata.ReleaseYear <= filter.ReleaseYearRange!.Max)
 | 
						|
            .WhereIf(hasPublicationFilter, s => filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))
 | 
						|
            .WhereIf(hasSeriesNameFilter, s => EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
 | 
						|
                                               || EF.Functions.Like(s.OriginalName!, $"%{filter.SeriesNameQuery}%")
 | 
						|
                                               || EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%"))
 | 
						|
            .Where(s => userLibraries.Contains(s.LibraryId)
 | 
						|
                        && formats.Contains(s.Format))
 | 
						|
            .Sort(userId, filter.SortOptions)
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        return query.AsSplitQuery();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
 | 
						|
    {
 | 
						|
        var metadataDto = await _context.SeriesMetadata
 | 
						|
            .Where(metadata => metadata.SeriesId == seriesId)
 | 
						|
            .Include(m => m.Genres.OrderBy(g => g.NormalizedTitle))
 | 
						|
            .Include(m => m.Tags.OrderBy(g => g.NormalizedTitle))
 | 
						|
            .Include(m => m.People)
 | 
						|
            .AsNoTracking()
 | 
						|
            .ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
 | 
						|
        if (metadataDto != null)
 | 
						|
        {
 | 
						|
            metadataDto.CollectionTags = await _context.CollectionTag
 | 
						|
                .Include(t => t.SeriesMetadatas)
 | 
						|
                .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId))
 | 
						|
                .ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
 | 
						|
                .AsNoTracking()
 | 
						|
                .OrderBy(t => t.Title.ToLower())
 | 
						|
                .AsSplitQuery()
 | 
						|
                .ToListAsync();
 | 
						|
        }
 | 
						|
 | 
						|
        return metadataDto;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams)
 | 
						|
    {
 | 
						|
        var userLibraries = _context.Library
 | 
						|
            .Include(l => l.AppUsers)
 | 
						|
            .Where(library => library.AppUsers.Any(user => user.Id == userId))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking()
 | 
						|
            .Select(library => library.Id)
 | 
						|
            .ToList();
 | 
						|
 | 
						|
        var query =  _context.CollectionTag
 | 
						|
            .Where(s => s.Id == collectionId)
 | 
						|
            .Include(c => c.SeriesMetadatas)
 | 
						|
            .ThenInclude(m => m.Series)
 | 
						|
            .SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId)))
 | 
						|
            .OrderBy(s => s.LibraryId)
 | 
						|
            .ThenBy(s => s.SortName.ToLower())
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IList<MangaFile>> GetFilesForSeries(int seriesId)
 | 
						|
    {
 | 
						|
        return await _context.Volume
 | 
						|
            .Where(v => v.SeriesId == seriesId)
 | 
						|
            .Include(v => v.Chapters)
 | 
						|
            .ThenInclude(c => c.Files)
 | 
						|
            .SelectMany(v => v.Chapters.SelectMany(c => c.Files))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking()
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IEnumerable<SeriesDto>> GetSeriesDtoForIdsAsync(IEnumerable<int> seriesIds, int userId)
 | 
						|
    {
 | 
						|
        var allowedLibraries = _context.Library
 | 
						|
            .Include(l => l.AppUsers)
 | 
						|
            .Where(library => library.AppUsers.Any(x => x.Id == userId))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Select(l => l.Id);
 | 
						|
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
 | 
						|
            .OrderBy(s => s.SortName.ToLower())
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsNoTracking()
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IList<string>> GetAllCoverImagesAsync()
 | 
						|
    {
 | 
						|
        return (await _context.Series
 | 
						|
            .Select(s => s.CoverImage)
 | 
						|
            .Where(t => !string.IsNullOrEmpty(t))
 | 
						|
            .ToListAsync())!;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IEnumerable<string>> GetLockedCoverImagesAsync()
 | 
						|
    {
 | 
						|
        return (await _context.Series
 | 
						|
            .Where(s => s.CoverImageLocked && !string.IsNullOrEmpty(s.CoverImage))
 | 
						|
            .Select(s => s.CoverImage)
 | 
						|
            .ToListAsync())!;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the number of series for a given library (or all libraries if libraryId is 0)
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryId">Defaults to 0, library to restrict count to</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    private async Task<int> GetSeriesCount(int libraryId = 0)
 | 
						|
    {
 | 
						|
        if (libraryId > 0)
 | 
						|
        {
 | 
						|
            return await _context.Series
 | 
						|
                .Where(s => s.LibraryId == libraryId)
 | 
						|
                .CountAsync();
 | 
						|
        }
 | 
						|
        return await _context.Series.CountAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the number of series that should be processed in parallel to optimize speed and memory. Minimum of 50
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryId">Defaults to 0 meaning no library</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    private async Task<Tuple<int, int>> GetChunkSize(int libraryId = 0)
 | 
						|
    {
 | 
						|
        var totalSeries = await GetSeriesCount(libraryId);
 | 
						|
        return new Tuple<int, int>(totalSeries, 50);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<Chunk> GetChunkInfo(int libraryId = 0)
 | 
						|
    {
 | 
						|
        var (totalSeries, chunkSize) = await GetChunkSize(libraryId);
 | 
						|
 | 
						|
        if (totalSeries == 0) return new Chunk()
 | 
						|
        {
 | 
						|
            TotalChunks = 0,
 | 
						|
            TotalSize = 0,
 | 
						|
            ChunkSize = 0
 | 
						|
        };
 | 
						|
 | 
						|
        var totalChunks = Math.Max((int) Math.Ceiling((totalSeries * 1.0) / chunkSize), 1);
 | 
						|
 | 
						|
        return new Chunk()
 | 
						|
        {
 | 
						|
            TotalSize = totalSeries,
 | 
						|
            ChunkSize = chunkSize,
 | 
						|
            TotalChunks = totalChunks
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds)
 | 
						|
    {
 | 
						|
        return await _context.SeriesMetadata
 | 
						|
            .Where(sm => seriesIds.Contains(sm.SeriesId))
 | 
						|
            .Include(sm => sm.CollectionTags)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Return recently updated series, regardless of read progress, and group the number of volume or chapters added.
 | 
						|
    /// </summary>
 | 
						|
    /// <remarks>This provides 2 levels of pagination. Fetching the individual chapters only looks at 3000. Then when performing grouping
 | 
						|
    /// in memory, we stop after 30 series. </remarks>
 | 
						|
    /// <param name="userId">Used to ensure user has access to libraries</param>
 | 
						|
    /// <param name="pageSize">How many entities to return</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30)
 | 
						|
    {
 | 
						|
        var seriesMap = new Dictionary<string, GroupedSeriesDto>();
 | 
						|
        var index = 0;
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
 | 
						|
        var items = (await GetRecentlyAddedChaptersQuery(userId));
 | 
						|
        if (userRating.AgeRating != AgeRating.NotApplicable)
 | 
						|
        {
 | 
						|
            items = items.RestrictAgainstAgeRestriction(userRating);
 | 
						|
        }
 | 
						|
 | 
						|
        foreach (var item in items)
 | 
						|
        {
 | 
						|
            if (seriesMap.Keys.Count == pageSize) break;
 | 
						|
 | 
						|
            if (item.SeriesName == null) continue;
 | 
						|
 | 
						|
 | 
						|
            if (seriesMap.TryGetValue(item.SeriesName + "_" + item.LibraryId, out var value))
 | 
						|
            {
 | 
						|
                value.Count += 1;
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                seriesMap[item.SeriesName + "_" + item.LibraryId] = new GroupedSeriesDto()
 | 
						|
                {
 | 
						|
                    LibraryId = item.LibraryId,
 | 
						|
                    LibraryType = item.LibraryType,
 | 
						|
                    SeriesId = item.SeriesId,
 | 
						|
                    SeriesName = item.SeriesName,
 | 
						|
                    Created = item.Created,
 | 
						|
                    Id = index,
 | 
						|
                    Format = item.Format,
 | 
						|
                    Count = 1,
 | 
						|
                };
 | 
						|
                index += 1;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return seriesMap.Values.AsEnumerable();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind)
 | 
						|
    {
 | 
						|
        var libraryIds = GetLibraryIdsForUser(userId);
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
 | 
						|
        var usersSeriesIds = _context.Series
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .Select(s => s.Id);
 | 
						|
 | 
						|
        var targetSeries = _context.SeriesRelation
 | 
						|
            .Where(sr =>
 | 
						|
                sr.SeriesId == seriesId && sr.RelationKind == kind && usersSeriesIds.Contains(sr.TargetSeriesId))
 | 
						|
            .Include(sr => sr.TargetSeries)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking()
 | 
						|
            .Select(sr => sr.TargetSeriesId);
 | 
						|
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => targetSeries.Contains(s.Id))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking()
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams)
 | 
						|
    {
 | 
						|
        var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended)
 | 
						|
            .Where(id => libraryId == 0 || id == libraryId);
 | 
						|
        var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
 | 
						|
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
        // Because this can be called from an API, we need to provide an additional check if the genre has anything the
 | 
						|
        // user with age restrictions can access
 | 
						|
 | 
						|
        var query = _context.Series
 | 
						|
            .Where(s => s.Metadata.Genres.Select(g => g.Id).Contains(genreId))
 | 
						|
            .Where(s => usersSeriesIds.Contains(s.Id))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
 | 
						|
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns a list of Series that the user Has fully read
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <param name="libraryId"></param>
 | 
						|
    /// <param name="userParams"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams)
 | 
						|
    {
 | 
						|
        var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended)
 | 
						|
            .Where(id => libraryId == 0 || id == libraryId);
 | 
						|
        var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
 | 
						|
        var distinctSeriesIdsWithProgress = _context.AppUserProgresses
 | 
						|
            .Where(s => usersSeriesIds.Contains(s.SeriesId))
 | 
						|
            .Select(p => p.SeriesId)
 | 
						|
            .Distinct();
 | 
						|
 | 
						|
        var query = _context.Series
 | 
						|
            .Where(s => distinctSeriesIdsWithProgress.Contains(s.Id) &&
 | 
						|
                        _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId)
 | 
						|
                            .Sum(s1 => s1.PagesRead) >= s.Pages)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<SeriesDto?> GetSeriesForMangaFile(int mangaFileId, int userId)
 | 
						|
    {
 | 
						|
        var libraryIds = GetLibraryIdsForUser(userId, 0, QueryContext.Search);
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
 | 
						|
        return await _context.MangaFile
 | 
						|
            .Where(m => m.Id == mangaFileId)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Select(f => f.Chapter)
 | 
						|
            .Select(c => c.Volume)
 | 
						|
            .Select(v => v.Series)
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<SeriesDto?> GetSeriesForChapter(int chapterId, int userId)
 | 
						|
    {
 | 
						|
        var libraryIds = GetLibraryIdsForUser(userId);
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
        return await _context.Chapter
 | 
						|
            .Where(m => m.Id == chapterId)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Select(c => c.Volume)
 | 
						|
            .Select(v => v.Series)
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Return a Series by Folder path. Null if not found.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="folder">This will be normalized in the query</param>
 | 
						|
    /// <param name="includes">Additional relationships to include with the base query</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None)
 | 
						|
    {
 | 
						|
        var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder);
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => s.FolderPath != null && s.FolderPath.Equals(normalized))
 | 
						|
            .Includes(includes)
 | 
						|
            .SingleOrDefaultAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
 | 
						|
        int userId, SeriesIncludes includes = SeriesIncludes.None)
 | 
						|
    {
 | 
						|
        var libraryIds = _context.Library.GetUserLibraries(userId);
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => normalizedNames.Contains(s.NormalizedName) ||
 | 
						|
                        normalizedNames.Contains(s.NormalizedLocalizedName))
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .Includes(includes)
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Finds a series by series name or localized name for a given library.
 | 
						|
    /// </summary>
 | 
						|
    /// <remarks>This pulls everything with the Series, so should be used only when needing tracking on all related tables</remarks>
 | 
						|
    /// <param name="seriesName"></param>
 | 
						|
    /// <param name="localizedName"></param>
 | 
						|
    /// <param name="libraryId"></param>
 | 
						|
    /// <param name="format"></param>
 | 
						|
    /// <param name="withFullIncludes">Defaults to true. This will query against all foreign keys (deep). If false, just the series will come back</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId,
 | 
						|
        MangaFormat format, bool withFullIncludes = true)
 | 
						|
    {
 | 
						|
        var normalizedSeries = seriesName.ToNormalized();
 | 
						|
        var normalizedLocalized = localizedName.ToNormalized();
 | 
						|
        var query = _context.Series
 | 
						|
            .Where(s => s.LibraryId == libraryId)
 | 
						|
            .Where(s => s.Format == format && format != MangaFormat.Unknown)
 | 
						|
            .Where(s =>
 | 
						|
                s.NormalizedName.Equals(normalizedSeries)
 | 
						|
                || s.NormalizedName.Equals(normalizedLocalized)
 | 
						|
 | 
						|
                || s.NormalizedLocalizedName.Equals(normalizedSeries)
 | 
						|
                || (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized))
 | 
						|
 | 
						|
                || (s.OriginalName != null && s.OriginalName.Equals(seriesName))
 | 
						|
            );
 | 
						|
        if (!withFullIncludes)
 | 
						|
        {
 | 
						|
            return query.SingleOrDefaultAsync();
 | 
						|
        }
 | 
						|
 | 
						|
        #nullable disable
 | 
						|
        query = query.Include(s => s.Library)
 | 
						|
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.People)
 | 
						|
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.Genres)
 | 
						|
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .ThenInclude(m => m.Tags)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(cm => cm.People)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(c => c.Tags)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(c => c.Genres)
 | 
						|
 | 
						|
            .Include(s => s.Volumes)
 | 
						|
            .ThenInclude(v => v.Chapters)
 | 
						|
            .ThenInclude(c => c.Files)
 | 
						|
 | 
						|
            .AsSplitQuery();
 | 
						|
        return query.SingleOrDefaultAsync();
 | 
						|
    #nullable enable
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Removes series that are not in the seenSeries list. Does not commit.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seenSeries"></param>
 | 
						|
    /// <param name="libraryId"></param>
 | 
						|
    public async Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId)
 | 
						|
    {
 | 
						|
        if (seenSeries.Count == 0) return Array.Empty<Series>();
 | 
						|
 | 
						|
        var ids = new List<int>();
 | 
						|
        foreach (var parsedSeries in seenSeries)
 | 
						|
        {
 | 
						|
            try
 | 
						|
            {
 | 
						|
                var seriesId = await _context.Series
 | 
						|
                    .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName &&
 | 
						|
                                s.LibraryId == libraryId)
 | 
						|
                    .Select(s => s.Id)
 | 
						|
                    .SingleOrDefaultAsync();
 | 
						|
                if (seriesId > 0)
 | 
						|
                {
 | 
						|
                    ids.Add(seriesId);
 | 
						|
                }
 | 
						|
            }
 | 
						|
            catch (Exception)
 | 
						|
            {
 | 
						|
                // This is due to v0.5.6 introducing bugs where we could have multiple series get duplicated and no way to delete them
 | 
						|
                // This here will delete the 2nd one as the first is the one to likely be used.
 | 
						|
                var sId = _context.Series
 | 
						|
                    .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName &&
 | 
						|
                                s.LibraryId == libraryId)
 | 
						|
                    .Select(s => s.Id)
 | 
						|
                    .OrderBy(s => s)
 | 
						|
                    .Last();
 | 
						|
                if (sId > 0)
 | 
						|
                {
 | 
						|
                    ids.Add(sId);
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        var seriesToRemove = await _context.Series
 | 
						|
            .Where(s => s.LibraryId == libraryId)
 | 
						|
            .Where(s => !ids.Contains(s.Id))
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        _context.Series.RemoveRange(seriesToRemove);
 | 
						|
 | 
						|
        return seriesToRemove;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams)
 | 
						|
    {
 | 
						|
        var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended)
 | 
						|
            .Where(id => libraryId == 0 || id == libraryId);
 | 
						|
        var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
 | 
						|
        var distinctSeriesIdsWithHighRating = _context.AppUserRating
 | 
						|
            .Where(s => usersSeriesIds.Contains(s.SeriesId) && s.Rating > 4)
 | 
						|
            .Select(p => p.SeriesId)
 | 
						|
            .Distinct();
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
 | 
						|
        var query = _context.Series
 | 
						|
            .Where(s => distinctSeriesIdsWithHighRating.Contains(s.Id))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .OrderByDescending(s => _context.AppUserRating.Where(r => r.SeriesId == s.Id).Select(r => r.Rating).Average())
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    public async Task<PagedList<SeriesDto>> GetQuickReads(int userId, int libraryId, UserParams userParams)
 | 
						|
    {
 | 
						|
        var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended)
 | 
						|
            .Where(id => libraryId == 0 || id == libraryId);
 | 
						|
        var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
 | 
						|
        var distinctSeriesIdsWithProgress = _context.AppUserProgresses
 | 
						|
            .Where(s => usersSeriesIds.Contains(s.SeriesId))
 | 
						|
            .Select(p => p.SeriesId)
 | 
						|
            .Distinct();
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
 | 
						|
 | 
						|
        var query = _context.Series
 | 
						|
            .Where(s => (
 | 
						|
                (s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
 | 
						|
                || (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
 | 
						|
                    && !distinctSeriesIdsWithProgress.Contains(s.Id) &&
 | 
						|
                         usersSeriesIds.Contains(s.Id))
 | 
						|
            .Where(s => s.Metadata.PublicationStatus != PublicationStatus.OnGoing)
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
 | 
						|
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams)
 | 
						|
    {
 | 
						|
        var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended)
 | 
						|
            .Where(id => libraryId == 0 || id == libraryId);
 | 
						|
        var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
 | 
						|
        var distinctSeriesIdsWithProgress = _context.AppUserProgresses
 | 
						|
            .Where(s => usersSeriesIds.Contains(s.SeriesId))
 | 
						|
            .Select(p => p.SeriesId)
 | 
						|
            .Distinct();
 | 
						|
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
 | 
						|
 | 
						|
        var query = _context.Series
 | 
						|
            .Where(s => (
 | 
						|
                            (s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
 | 
						|
                             || (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
 | 
						|
                        && !distinctSeriesIdsWithProgress.Contains(s.Id) &&
 | 
						|
                        usersSeriesIds.Contains(s.Id))
 | 
						|
            .Where(s => s.Metadata.PublicationStatus == PublicationStatus.OnGoing)
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
 | 
						|
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId)
 | 
						|
    {
 | 
						|
        var libraryIds = _context.Library.GetUserLibraries(userId);
 | 
						|
        var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
 | 
						|
        return new RelatedSeriesDto()
 | 
						|
        {
 | 
						|
            SourceSeriesId = seriesId,
 | 
						|
            Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation, userRating),
 | 
						|
            Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character, userRating),
 | 
						|
            Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel, userRating),
 | 
						|
            Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel, userRating),
 | 
						|
            Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains, userRating),
 | 
						|
            SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory, userRating),
 | 
						|
            SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff, userRating),
 | 
						|
            Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other, userRating),
 | 
						|
            AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting, userRating),
 | 
						|
            AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion, userRating),
 | 
						|
            Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi, userRating),
 | 
						|
            // Parent = await _context.Series
 | 
						|
            //     .SelectMany(s =>
 | 
						|
            //         s.TargetSeries.Where(r => r.TargetSeriesId == seriesId
 | 
						|
            //                                  && usersSeriesIds.Contains(r.TargetSeriesId)
 | 
						|
            //                                  && r.RelationKind != RelationKind.Prequel
 | 
						|
            //                                  && r.RelationKind != RelationKind.Sequel
 | 
						|
            //                                  && r.RelationKind != RelationKind.Edition)
 | 
						|
            //             .Select(sr => sr.Series))
 | 
						|
            //     .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            //     .AsSplitQuery()
 | 
						|
            //     .AsNoTracking()
 | 
						|
            //     .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            //     .ToListAsync(),
 | 
						|
            Parent = await _context.SeriesRelation
 | 
						|
                .Where(r => r.TargetSeriesId == seriesId
 | 
						|
                                              && usersSeriesIds.Contains(r.TargetSeriesId)
 | 
						|
                                              && r.RelationKind != RelationKind.Prequel
 | 
						|
                                              && r.RelationKind != RelationKind.Sequel
 | 
						|
                                              && r.RelationKind != RelationKind.Edition)
 | 
						|
                        .Select(sr => sr.Series)
 | 
						|
                .RestrictAgainstAgeRestriction(userRating)
 | 
						|
                .AsSplitQuery()
 | 
						|
                .AsNoTracking()
 | 
						|
                .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
                .ToListAsync(),
 | 
						|
            Editions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Edition, userRating)
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    private IQueryable<int> GetSeriesIdsForLibraryIds(IQueryable<int> libraryIds)
 | 
						|
    {
 | 
						|
        return _context.Series
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .Select(s => s.Id);
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<IEnumerable<SeriesDto>> GetRelatedSeriesQuery(int seriesId, IEnumerable<int> usersSeriesIds, RelationKind kind, AgeRestriction userRating)
 | 
						|
    {
 | 
						|
        return await _context.Series.SelectMany(s =>
 | 
						|
            s.Relations.Where(sr => sr.RelationKind == kind && sr.SeriesId == seriesId && usersSeriesIds.Contains(sr.TargetSeriesId))
 | 
						|
                .Select(sr => sr.TargetSeries))
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking()
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId)
 | 
						|
    {
 | 
						|
        var libraryIds = await _context.AppUser
 | 
						|
            .Where(u => u.Id == userId)
 | 
						|
            .SelectMany(u => u.Libraries)
 | 
						|
            .Where(l => l.IncludeInDashboard)
 | 
						|
            .Select(l => l.Id)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12);
 | 
						|
        return _context.Chapter
 | 
						|
            .Where(c => c.Created >= withinLastWeek).AsNoTracking()
 | 
						|
            .Include(c => c.Volume)
 | 
						|
            .ThenInclude(v => v.Series)
 | 
						|
            .ThenInclude(s => s.Library)
 | 
						|
            .OrderByDescending(c => c.Created)
 | 
						|
            .Select(c => new RecentlyAddedSeries()
 | 
						|
            {
 | 
						|
                LibraryId = c.Volume.Series.LibraryId,
 | 
						|
                LibraryType = c.Volume.Series.Library.Type,
 | 
						|
                Created = c.Created,
 | 
						|
                SeriesId = c.Volume.Series.Id,
 | 
						|
                SeriesName = c.Volume.Series.Name,
 | 
						|
                VolumeId = c.VolumeId,
 | 
						|
                ChapterId = c.Id,
 | 
						|
                Format = c.Volume.Series.Format,
 | 
						|
                ChapterNumber = c.Number,
 | 
						|
                ChapterRange = c.Range,
 | 
						|
                IsSpecial = c.IsSpecial,
 | 
						|
                VolumeNumber = c.Volume.Number,
 | 
						|
                ChapterTitle = c.Title,
 | 
						|
                AgeRating = c.Volume.Series.Metadata.AgeRating
 | 
						|
            })
 | 
						|
            .AsSplitQuery()
 | 
						|
            .Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId))
 | 
						|
            .AsEnumerable();
 | 
						|
    }
 | 
						|
 | 
						|
    [Obsolete("Use GetWantToReadForUserV2Async")]
 | 
						|
    public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
 | 
						|
    {
 | 
						|
        var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
 | 
						|
        var query = _context.AppUser
 | 
						|
            .Where(user => user.Id == userId)
 | 
						|
            .SelectMany(u => u.WantToRead)
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        var filteredQuery = await CreateFilteredSearchQueryable(userId, 0, filter, query);
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<PagedList<SeriesDto>> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter)
 | 
						|
    {
 | 
						|
        var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
 | 
						|
        var query = _context.AppUser
 | 
						|
            .Where(user => user.Id == userId)
 | 
						|
            .SelectMany(u => u.WantToRead)
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking();
 | 
						|
 | 
						|
        var filteredQuery = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.None, query);
 | 
						|
 | 
						|
        return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IList<Series>> GetWantToReadForUserAsync(int userId)
 | 
						|
    {
 | 
						|
        var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
 | 
						|
        return await _context.AppUser
 | 
						|
            .Where(user => user.Id == userId)
 | 
						|
            .SelectMany(u => u.WantToRead)
 | 
						|
            .Where(s => libraryIds.Contains(s.LibraryId))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking()
 | 
						|
            .ToListAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Uses multiple names to find a match against a series then ensures the user has appropriate access to it. If not, returns null.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <param name="names"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIdsForUser(int userId, IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl)
 | 
						|
    {
 | 
						|
        var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
 | 
						|
        var libraryIds = await _context.Library.GetUserLibrariesByType(userId, libraryType).ToListAsync();
 | 
						|
        var normalizedNames = names.Select(n => n.ToNormalized()).ToList();
 | 
						|
        SeriesDto? result = null;
 | 
						|
        if (!string.IsNullOrEmpty(aniListUrl) || !string.IsNullOrEmpty(malUrl))
 | 
						|
        {
 | 
						|
            result =  await _context.Series
 | 
						|
                .RestrictAgainstAgeRestriction(userRating)
 | 
						|
                .Where(s => !string.IsNullOrEmpty(s.Metadata.WebLinks))
 | 
						|
                .Where(s => libraryIds.Contains(s.Library.Id))
 | 
						|
                .WhereIf(!string.IsNullOrEmpty(aniListUrl), s => s.Metadata.WebLinks.Contains(aniListUrl))
 | 
						|
                .WhereIf(!string.IsNullOrEmpty(malUrl), s => s.Metadata.WebLinks.Contains(malUrl))
 | 
						|
                .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
                .AsSplitQuery()
 | 
						|
                .FirstOrDefaultAsync();
 | 
						|
        }
 | 
						|
 | 
						|
        if (result != null) return result;
 | 
						|
 | 
						|
        return await _context.Series
 | 
						|
            .RestrictAgainstAgeRestriction(userRating)
 | 
						|
            .Where(s => normalizedNames.Contains(s.NormalizedName) ||
 | 
						|
                        normalizedNames.Contains(s.NormalizedLocalizedName))
 | 
						|
            .Where(s => libraryIds.Contains(s.Library.Id))
 | 
						|
            .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
 | 
						|
            .AsSplitQuery()
 | 
						|
            .FirstOrDefaultAsync(); // Some users may have improperly configured libraries
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the Average rating for all users within Kavita instance
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    public async Task<int> GetAverageUserRating(int seriesId, int userId)
 | 
						|
    {
 | 
						|
        // If there is 0 or 1 rating and that rating is you, return 0 back
 | 
						|
        var countOfRatingsThatAreUser = await _context.AppUserRating
 | 
						|
            .Where(r => r.SeriesId == seriesId && r.HasBeenRated).CountAsync(u => u.AppUserId == userId);
 | 
						|
        if (countOfRatingsThatAreUser == 1)
 | 
						|
        {
 | 
						|
            return 0;
 | 
						|
        }
 | 
						|
        var avg = (await _context.AppUserRating
 | 
						|
            .Where(r => r.SeriesId == seriesId && r.HasBeenRated)
 | 
						|
            .AverageAsync(r => (int?) r.Rating));
 | 
						|
        return avg.HasValue ? (int) (avg.Value * 20) : 0;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task RemoveFromOnDeck(int seriesId, int userId)
 | 
						|
    {
 | 
						|
        var existingEntry = await _context.AppUserOnDeckRemoval
 | 
						|
            .Where(u => u.Id == userId && u.SeriesId == seriesId)
 | 
						|
            .AnyAsync();
 | 
						|
        if (existingEntry) return;
 | 
						|
        _context.AppUserOnDeckRemoval.Add(new AppUserOnDeckRemoval()
 | 
						|
        {
 | 
						|
            SeriesId = seriesId,
 | 
						|
            AppUserId = userId
 | 
						|
        });
 | 
						|
        await _context.SaveChangesAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task ClearOnDeckRemoval(int seriesId, int userId)
 | 
						|
    {
 | 
						|
        var existingEntry = await _context.AppUserOnDeckRemoval
 | 
						|
            .Where(u => u.AppUserId == userId && u.SeriesId == seriesId)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (existingEntry == null) return;
 | 
						|
        _context.AppUserOnDeckRemoval.Remove(existingEntry);
 | 
						|
        await _context.SaveChangesAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<bool> IsSeriesInWantToRead(int userId, int seriesId)
 | 
						|
    {
 | 
						|
        var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
 | 
						|
        return await _context.AppUser
 | 
						|
            .Where(user => user.Id == userId)
 | 
						|
            .SelectMany(u => u.WantToRead.Where(s => s.Id == seriesId && libraryIds.Contains(s.LibraryId)))
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking()
 | 
						|
            .AnyAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId)
 | 
						|
    {
 | 
						|
        var info = await _context.Series
 | 
						|
            .Where(s => s.LibraryId == libraryId)
 | 
						|
            .AsNoTracking()
 | 
						|
            .Where(s => s.FolderPath != null)
 | 
						|
            .Select(s => new SeriesModified()
 | 
						|
            {
 | 
						|
                LastScanned = s.LastFolderScanned,
 | 
						|
                SeriesName = s.Name,
 | 
						|
                FolderPath = s.FolderPath,
 | 
						|
                Format = s.Format,
 | 
						|
                LibraryRoots = s.Library.Folders.Select(f => f.Path)
 | 
						|
            }).ToListAsync();
 | 
						|
 | 
						|
        var map = new Dictionary<string, IList<SeriesModified>>();
 | 
						|
        foreach (var series in info)
 | 
						|
        {
 | 
						|
            if (series.FolderPath == null) continue;
 | 
						|
            if (!map.ContainsKey(series.FolderPath))
 | 
						|
            {
 | 
						|
                map.Add(series.FolderPath, new List<SeriesModified>()
 | 
						|
                {
 | 
						|
                    series
 | 
						|
                });
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                map[series.FolderPath].Add(series);
 | 
						|
            }
 | 
						|
 | 
						|
        }
 | 
						|
 | 
						|
        return map;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the highest Age Rating for a list of Series
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesIds"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds)
 | 
						|
    {
 | 
						|
        return await _context.Series
 | 
						|
            .Where(s => seriesIds.Contains(s.Id))
 | 
						|
            .Include(s => s.Metadata)
 | 
						|
            .Select(s => s.Metadata.AgeRating)
 | 
						|
            .OrderBy(s => s)
 | 
						|
            .LastOrDefaultAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns all library ids for a user
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <param name="libraryId">0 for no library filter</param>
 | 
						|
    /// <param name="queryContext">Defaults to None - The context behind this query, so appropriate restrictions can be placed</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    private IQueryable<int> GetLibraryIdsForUser(int userId, int libraryId = 0, QueryContext queryContext = QueryContext.None)
 | 
						|
    {
 | 
						|
        var user = _context.AppUser
 | 
						|
            .AsSplitQuery()
 | 
						|
            .AsNoTracking()
 | 
						|
            .Where(u => u.Id == userId)
 | 
						|
            .AsSingleQuery();
 | 
						|
 | 
						|
        if (libraryId == 0)
 | 
						|
        {
 | 
						|
            return user.SelectMany(l => l.Libraries)
 | 
						|
                .IsRestricted(queryContext)
 | 
						|
                .Select(lib => lib.Id);
 | 
						|
        }
 | 
						|
 | 
						|
        return user.SelectMany(l => l.Libraries)
 | 
						|
            .Where(lib => lib.Id == libraryId)
 | 
						|
            .IsRestricted(queryContext)
 | 
						|
            .Select(lib => lib.Id);
 | 
						|
    }
 | 
						|
 | 
						|
}
 |