Kavita/API/Data/Repositories/SeriesRepository.cs
Joseph Milazzo cf7a9aa71e
Reading Lists & More (#564)
* Added continous reading to the book reader. Clicking on the max pages to right of progress bar will now go to last page.

* Forgot a file for continous book reading

* Fixed up some code regarding transitioning between chapters. Arrows now show to represent a chapter transition.

* Laid the foundation for reading lists

* All foundation is laid out. Actions are wired in the UI. Backend repository is setup. Redid the migration to have ReadingList track modification so we can order them for the user.

* Updated add modal to have basic skeleton

* Hooked up ability to fetch reading lists from backend

* Made a huge performance improvement to GetChapterIdsForSeriesAsync() by reducing a JOIN and an iteration loop. Improvement went from 2 seconds -> 200 ms.

* Implemented the ability to add all chapters in a series to a reading list.

* Fixed issue with adding new items to reading list not being in a logical order. Lots of work on getting all the information around the reading list view. Added some foreign keys back to chapter so delete should clean up after itself.

* Added ability to open directly the series

* Reading List Items now have progress attached

* Hooked up list deletion and added a case where if doesn't exist on load, then redirect to library.

* Lots of changes. Introduced a dashboard component for the main app. This will sit on libraries route for now and will have 3 tabs to show different sections.

Moved libraries reel down to bottom as people are more likely to access recently added or in progress than explore their whole library.

Note: Bundles are messed up, they need to be reoptimized and routes need to be updated.

* Added pagination to the reading lists api and implemented a page to show all lists

* Cleaned up old code from all-collections component so now it only handles all collections and doesn't have the old code for an individual collection

* Hooked in actions and navigation on reading lists

* When the user re-arranges items, they are now persisted

* Implemented remove read, but performance is pretty poor. Needs to be optimized.

* Lots of API fixes for adding items to a series, returning items, etc. Committing before fixing incorrect fetches of items for a readingListId.

* Rewrote the joins for GetReadingListItemDtosByIdAsync() to not return extra records.

* Remove bug marker now that it is fixed

* Refactor update-by-series to move more of the code to a re-usable function for update-by-volume/chapter APIs

* Implemented the ability to add via series, volume or chapter.

* Added OPDS support for reading lists. This included adding VolumeId to the ReadingListDto.

* Fixed a bug with deleting items

* After we create a library inform user that a scan has started

* Added some extra help information for users on directory picker, since linux users were getting confused.

* Setup for the reading functionality

* Fixed an issue where opening the edit series modal and pressing save without doing anything would empty collection tags. Would happen often when editing cover images.

* Fixed get-next-chapter for reading list. Refactored all methods to use the new GetUserIdByUsernameAsync(), which is much faster and uses less memory.

* Hooked in prev chapter for continuous reading with reading list

* Hooked up the read code for manga reader and book reader to have list id passed

* Manga reader now functions completely with reading lists

* Implemented reading list and incognito mode into book reader

* Refactored some common reading code into reader service

* Added support for "Series -  - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz" format that can occur with FMD2.

* Implemented continuous reading with a reading list between different readers. This incurs a 3x performance hit on the book info api.

* style changes. Don't emit an event if position of draggable item hasn't changed

* Styling and added the edit reading list flow.

* Cleaned up some extra spaces when actionables isn't shown. Lots of cleanup for promoted lists.

* Refactored some filter code to a common service

* Added an RBS check in getting Items for a given user.

* Code smells

* More smells
2021-09-08 12:03:27 -05:00

459 lines
17 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.DTOs;
using API.DTOs.Filtering;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Interfaces;
using API.Interfaces.Repositories;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories
{
public class SeriesRepository : ISeriesRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
private readonly NaturalSortComparer _naturalSortComparer = new ();
public SeriesRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Add(Series series)
{
_context.Series.Add(series);
}
public void Update(Series series)
{
_context.Entry(series).State = EntityState.Modified;
}
public async Task<bool> SaveAllAsync()
{
return await _context.SaveChangesAsync() > 0;
}
public bool SaveAll()
{
return _context.SaveChanges() > 0;
}
public async Task<Series> GetSeriesByNameAsync(string name)
{
return await _context.Series.SingleOrDefaultAsync(x => x.Name == name);
}
public async Task<bool> DoesSeriesNameExistInLibrary(string name)
{
var libraries = _context.Series
.AsNoTracking()
.Where(x => x.Name == name)
.Select(s => s.LibraryId);
return await _context.Series
.AsNoTracking()
.Where(s => libraries.Contains(s.LibraryId) && s.Name == name)
.CountAsync() > 1;
}
public Series GetSeriesByName(string name)
{
return _context.Series.SingleOrDefault(x => x.Name == name);
}
public async Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId)
{
return await _context.Series
.Where(s => s.LibraryId == libraryId)
.OrderBy(s => s.SortName)
.ToListAsync();
}
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var formats = filter.GetSqlFilter();
var query = _context.Series
.Where(s => s.LibraryId == libraryId && formats.Contains(s.Format))
.OrderBy(s => s.SortName)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery)
{
return await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|| EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")
|| EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
.Include(s => s.Library)
.OrderBy(s => s.SortName)
.AsNoTracking()
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId)
{
var volumes = await _context.Volume
.Where(vol => vol.SeriesId == seriesId)
.Include(vol => vol.Chapters)
.OrderBy(volume => volume.Number)
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.ToListAsync();
await AddVolumeModifiers(userId, volumes);
SortSpecialChapters(volumes);
return volumes;
}
private void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
{
foreach (var v in volumes.Where(vDto => vDto.Number == 0))
{
v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList();
}
}
public async Task<IEnumerable<Volume>> GetVolumes(int seriesId)
{
return await _context.Volume
.Where(vol => vol.SeriesId == seriesId)
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.OrderBy(vol => vol.Number)
.ToListAsync();
}
public async Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId)
{
var series = await _context.Series.Where(x => x.Id == seriesId)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.SingleAsync();
var seriesList = new List<SeriesDto>() {series};
await AddSeriesModifiers(userId, seriesList);
return seriesList[0];
}
public async Task<Volume> GetVolumeAsync(int volumeId)
{
return await _context.Volume
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.SingleOrDefaultAsync(vol => vol.Id == volumeId);
}
public async Task<VolumeDto> GetVolumeDtoAsync(int volumeId)
{
return await _context.Volume
.Where(vol => vol.Id == volumeId)
.AsNoTracking()
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.SingleAsync();
}
public async Task<VolumeDto> GetVolumeDtoAsync(int volumeId, int userId)
{
var volume = await _context.Volume
.Where(vol => vol.Id == volumeId)
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.SingleAsync(vol => vol.Id == volumeId);
var volumeList = new List<VolumeDto>() {volume};
await AddVolumeModifiers(userId, volumeList);
return volumeList[0];
}
/// <summary>
/// Returns all volumes that contain a seriesId in passed array.
/// </summary>
/// <param name="seriesIds"></param>
/// <returns></returns>
public async Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(int[] seriesIds)
{
return await _context.Volume
.Where(v => seriesIds.Contains(v.SeriesId))
.ToListAsync();
}
public async Task<bool> DeleteSeriesAsync(int seriesId)
{
var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync();
_context.Series.Remove(series);
return await _context.SaveChangesAsync() > 0;
}
public async Task<Volume> GetVolumeByIdAsync(int volumeId)
{
return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId);
}
public async Task<Series> GetSeriesByIdAsync(int seriesId)
{
return await _context.Series
.Include(s => s.Volumes)
.Include(s => s.Metadata)
.ThenInclude(m => m.CollectionTags)
.Where(s => s.Id == seriesId)
.SingleOrDefaultAsync();
}
public async Task<int[]> GetChapterIdsForSeriesAsync(int[] seriesIds)
{
var volumes = await _context.Volume
.Where(v => seriesIds.Contains(v.SeriesId))
.Include(v => v.Chapters)
.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();
}
public async Task AddSeriesModifiers(int userId, List<SeriesDto> series)
{
var userProgress = await _context.AppUserProgresses
.Where(p => p.AppUserId == userId && series.Select(s => s.Id).Contains(p.SeriesId))
.ToListAsync();
var userRatings = await _context.AppUserRating
.Where(r => r.AppUserId == userId && series.Select(s => s.Id).Contains(r.SeriesId))
.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) continue;
s.UserRating = rating.Rating;
s.UserReview = rating.Review;
}
}
public async Task<byte[]> GetVolumeCoverImageAsync(int volumeId)
{
return await _context.Volume
.Where(v => v.Id == volumeId)
.Select(v => v.CoverImage)
.AsNoTracking()
.SingleOrDefaultAsync();
}
public async Task<byte[]> GetSeriesCoverImageAsync(int seriesId)
{
return await _context.Series
.Where(s => s.Id == seriesId)
.Select(s => s.CoverImage)
.AsNoTracking()
.SingleOrDefaultAsync();
}
private async Task AddVolumeModifiers(int userId, IReadOnlyCollection<VolumeDto> volumes)
{
var userProgress = await _context.AppUserProgresses
.Where(p => p.AppUserId == userId && volumes.Select(s => s.Id).Contains(p.VolumeId))
.AsNoTracking()
.ToListAsync();
foreach (var v in volumes)
{
foreach (var c in v.Chapters)
{
c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead);
}
v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead);
}
}
/// <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>
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var formats = filter.GetSqlFilter();
if (libraryId == 0)
{
var userLibraries = _context.Library
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(user => user.Id == userId))
.AsNoTracking()
.Select(library => library.Id)
.ToList();
var allQuery = _context.Series
.Where(s => userLibraries.Contains(s.LibraryId) && formats.Contains(s.Format))
.OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(allQuery, userParams.PageNumber, userParams.PageSize);
}
var query = _context.Series
.Where(s => s.LibraryId == libraryId && formats.Contains(s.Format))
.OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
/// <summary>
/// Returns Series that the user has some partial progress on
/// </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<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter)
{
var formats = filter.GetSqlFilter();
IList<int> userLibraries;
if (libraryId == 0)
{
userLibraries = _context.Library
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(user => user.Id == userId))
.AsNoTracking()
.Select(library => library.Id)
.ToList();
}
else
{
userLibraries = new List<int>() {libraryId};
}
var series = _context.Series
.Where(s => formats.Contains(s.Format) && userLibraries.Contains(s.LibraryId))
.Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new
{
Series = s,
PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId).Sum(s1 => s1.PagesRead),
progress.AppUserId,
LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId).Max(p => p.LastModified)
})
.AsNoTracking();
var retSeries = series.Where(s => s.AppUserId == userId
&& s.PagesRead > 0
&& s.PagesRead < s.Series.Pages)
.OrderByDescending(s => s.LastModified)
.Select(s => s.Series)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
// Pagination does not work for this query as when we pull the data back, we get multiple rows of the same series. See controller for pagination code
return await retSeries.ToListAsync();
}
public async Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId)
{
var metadataDto = await _context.SeriesMetadata
.Where(metadata => metadata.SeriesId == seriesId)
.AsNoTracking()
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
if (metadataDto != null)
{
metadataDto.Tags = await _context.CollectionTag
.Include(t => t.SeriesMetadatas)
.Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId))
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.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))
.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)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.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))
.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))
.Select(l => l.Id);
return await _context.Series
.Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
.OrderBy(s => s.SortName)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.AsSplitQuery()
.ToListAsync();
}
}
}