From 2887fab53f135017b9b9903ce1c3850f283c45a4 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Mon, 15 Feb 2021 13:08:30 -0600 Subject: [PATCH] Implements search functionality and prepares for upcoming paging in v0.3. --- API/Controllers/LibraryController.cs | 27 +++++++++++++++++++++++- API/DTOs/ImageDto.cs | 3 ++- API/DTOs/SearchQueryDto.cs | 2 +- API/DTOs/SearchResultDto.cs | 9 ++++++++ API/Data/LibraryRepository.cs | 9 ++++++++ API/Data/SeriesRepository.cs | 25 ++++++++++++++++++++-- API/Data/UnitOfWork.cs | 7 +++++-- API/Entities/FTSSeries.cs | 14 +++++++++++++ API/Entities/Series.cs | 1 + API/Extensions/HttpExtensions.cs | 16 +++++++++++--- API/Helpers/AutoMapperProfiles.cs | 7 +++++++ API/Helpers/PagedList.cs | 31 +++++++++++++++++++++++++--- API/Helpers/PaginationHeader.cs | 13 +++++++++++- API/Helpers/UserParams.cs | 10 ++++++++- API/Interfaces/ILibraryRepository.cs | 1 + API/Interfaces/ISeriesRepository.cs | 12 +++++++++++ API/Services/MetadataService.cs | 2 ++ 17 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 API/Entities/FTSSeries.cs diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index b068ffa0b..eda23081d 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -2,14 +2,18 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; +using API.Data; using API.DTOs; using API.Entities; using API.Extensions; +using API.Helpers; using API.Interfaces; using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Controllers @@ -22,16 +26,18 @@ namespace API.Controllers private readonly IMapper _mapper; private readonly ITaskScheduler _taskScheduler; private readonly IUnitOfWork _unitOfWork; + private readonly DataContext _dataContext; // TODO: Remove, only for FTS prototyping public LibraryController(IDirectoryService directoryService, ILogger logger, IMapper mapper, ITaskScheduler taskScheduler, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, DataContext dataContext) { _directoryService = directoryService; _logger = logger; _mapper = mapper; _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; + _dataContext = dataContext; } /// @@ -213,5 +219,24 @@ namespace API.Controllers return Ok(); } + + [HttpGet("search")] + public async Task>> Search(string queryString) + { + //NOTE: What about normalizing search query and only searching against normalizedname in Series? + // So One Punch would match One-Punch + // This also means less indexes we need. + queryString = queryString.Replace(@"%", ""); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + // Get libraries user has access to + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + + if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + + var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), queryString); + + return Ok(series); + } } } \ No newline at end of file diff --git a/API/DTOs/ImageDto.cs b/API/DTOs/ImageDto.cs index 18ffe7178..6e3f0ae7c 100644 --- a/API/DTOs/ImageDto.cs +++ b/API/DTOs/ImageDto.cs @@ -9,7 +9,8 @@ public int Height { get; init; } public string Format { get; init; } public byte[] Content { get; init; } - public int Chapter { get; set; } + //public int Chapter { get; set; } public string MangaFileName { get; set; } + public bool NeedsSplitting { get; set; } } } \ No newline at end of file diff --git a/API/DTOs/SearchQueryDto.cs b/API/DTOs/SearchQueryDto.cs index ea31a9266..b637f952b 100644 --- a/API/DTOs/SearchQueryDto.cs +++ b/API/DTOs/SearchQueryDto.cs @@ -2,6 +2,6 @@ { public class SearchQueryDto { - + public string QueryString { get; init; } } } \ No newline at end of file diff --git a/API/DTOs/SearchResultDto.cs b/API/DTOs/SearchResultDto.cs index 89c5bd349..3e154d3b7 100644 --- a/API/DTOs/SearchResultDto.cs +++ b/API/DTOs/SearchResultDto.cs @@ -2,6 +2,15 @@ { public class SearchResultDto { + public int SeriesId { get; init; } + public string Name { get; init; } + public string OriginalName { get; init; } + public string SortName { get; init; } + public byte[] CoverImage { get; init; } // This should be optional or a thumbImage (much smaller) + + // Grouping information + public string LibraryName { get; set; } + public int LibraryId { get; set; } } } \ No newline at end of file diff --git a/API/Data/LibraryRepository.cs b/API/Data/LibraryRepository.cs index 9fe73a193..80dbbb553 100644 --- a/API/Data/LibraryRepository.cs +++ b/API/Data/LibraryRepository.cs @@ -60,6 +60,15 @@ namespace API.Data return await _context.SaveChangesAsync() > 0; } + public async Task> GetLibrariesForUserIdAsync(int userId) + { + return await _context.Library + .Include(l => l.AppUsers) + .Where(l => l.AppUsers.Select(ap => ap.Id).Contains(userId)) + .AsNoTracking() + .ToListAsync(); + } + public async Task> GetLibraryDtosAsync() { return await _context.Library diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index e682648a6..8c41adfd2 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -9,6 +9,7 @@ using API.Interfaces; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace API.Data { @@ -16,11 +17,13 @@ namespace API.Data { private readonly DataContext _context; private readonly IMapper _mapper; + private readonly ILogger _logger; - public SeriesRepository(DataContext context, IMapper mapper) + public SeriesRepository(DataContext context, IMapper mapper, ILogger logger) { _context = context; _mapper = mapper; + _logger = logger; } public void Add(Series series) @@ -74,7 +77,25 @@ namespace API.Data await AddSeriesModifiers(userId, series); - Console.WriteLine("Processed GetSeriesDtoForLibraryIdAsync in {0} milliseconds", sw.ElapsedMilliseconds); + _logger.LogDebug("Processed GetSeriesDtoForLibraryIdAsync in {ElapsedMilliseconds} milliseconds", sw.ElapsedMilliseconds); + return series; + } + + public async Task> SearchSeries(int[] libraryIds, string searchQuery) + { + var sw = Stopwatch.StartNew(); + var series = await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") + || EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")) + .Include(s => s.Library) // NOTE: Is there a way to do this faster? + .OrderBy(s => s.SortName) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + + + _logger.LogDebug("Processed SearchSeries in {ElapsedMilliseconds} milliseconds", sw.ElapsedMilliseconds); return series; } diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 6cffc1392..4b8ffac81 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -3,6 +3,7 @@ using API.Entities; using API.Interfaces; using AutoMapper; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; namespace API.Data { @@ -11,15 +12,17 @@ namespace API.Data private readonly DataContext _context; private readonly IMapper _mapper; private readonly UserManager _userManager; + private readonly ILogger _seriesLogger; - public UnitOfWork(DataContext context, IMapper mapper, UserManager userManager) + public UnitOfWork(DataContext context, IMapper mapper, UserManager userManager, ILogger seriesLogger) { _context = context; _mapper = mapper; _userManager = userManager; + _seriesLogger = seriesLogger; } - public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper); + public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper, _seriesLogger); public IUserRepository UserRepository => new UserRepository(_context, _userManager); public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper); diff --git a/API/Entities/FTSSeries.cs b/API/Entities/FTSSeries.cs new file mode 100644 index 000000000..a9c207ce2 --- /dev/null +++ b/API/Entities/FTSSeries.cs @@ -0,0 +1,14 @@ +namespace API.Entities +{ + public class FTSSeries + { + public int RowId { get; set; } + public Series Series { get; set; } + + public string Name { get; set; } + public string OriginalName { get; set; } + + public string Match { get; set; } + public double? Rank { get; set; } + } +} \ No newline at end of file diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index ddc9a3b61..a0f7b119c 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -30,6 +30,7 @@ namespace API.Entities public DateTime Created { get; set; } public DateTime LastModified { get; set; } public byte[] CoverImage { get; set; } + // NOTE: Do I want to store a thumbImage for search results? /// /// Sum of all Volume page counts /// diff --git a/API/Extensions/HttpExtensions.cs b/API/Extensions/HttpExtensions.cs index b930e2652..5a4b4238d 100644 --- a/API/Extensions/HttpExtensions.cs +++ b/API/Extensions/HttpExtensions.cs @@ -1,7 +1,17 @@ -namespace API.Extensions +using System.Text.Json; +using API.Helpers; +using Microsoft.AspNetCore.Http; + +namespace API.Extensions { - public class HttpExtensions + public static class HttpExtensions { - + public static void AddPaginationHeader(this HttpResponse response, int currentPage, + int itemsPerPage, int totalItems, int totalPages) + { + var paginationHeader = new PaginationHeader(currentPage, itemsPerPage, totalItems, totalPages); + response.Headers.Add("Pagination", JsonSerializer.Serialize(paginationHeader)); + response.Headers.Add("Access-Control-Expose-Headers", "Pagination"); + } } } \ No newline at end of file diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 4994cbb0d..f5d670b59 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -22,6 +22,13 @@ namespace API.Helpers CreateMap(); CreateMap(); + + CreateMap() + .ForMember(dest => dest.SeriesId, + opt => opt.MapFrom(src => src.Id)) + .ForMember(dest => dest.LibraryName, + opt => opt.MapFrom(src => src.Library.Name)); + CreateMap() .ForMember(dest => dest.Folders, diff --git a/API/Helpers/PagedList.cs b/API/Helpers/PagedList.cs index b5a8fae03..0900f02a5 100644 --- a/API/Helpers/PagedList.cs +++ b/API/Helpers/PagedList.cs @@ -1,7 +1,32 @@ -namespace API.Helpers +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace API.Helpers { - public class PagedList + public class PagedList : List { - + public PagedList(IEnumerable items, int count, int pageNumber, int pageSize) + { + CurrentPage = pageNumber; + TotalPages = (int) Math.Ceiling(count / (double) pageSize); + PageSize = pageSize; + TotalCount = count; + AddRange(items); + } + + public int CurrentPage { get; set; } + public int TotalPages { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + + public static async Task> CreateAsync(IQueryable source, int pageNumber, int pageSize) + { + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + return new PagedList(items, count, pageNumber, pageSize); + } } } \ No newline at end of file diff --git a/API/Helpers/PaginationHeader.cs b/API/Helpers/PaginationHeader.cs index ea0140d4c..8d24eeca0 100644 --- a/API/Helpers/PaginationHeader.cs +++ b/API/Helpers/PaginationHeader.cs @@ -2,6 +2,17 @@ { public class PaginationHeader { - + public PaginationHeader(int currentPage, int itemsPerPage, int totalItems, int totalPages) + { + CurrentPage = currentPage; + ItemsPerPage = itemsPerPage; + TotalItems = totalItems; + TotalPages = totalPages; + } + + public int CurrentPage { get; set; } + public int ItemsPerPage { get; set; } + public int TotalItems { get; set; } + public int TotalPages { get; set; } } } \ No newline at end of file diff --git a/API/Helpers/UserParams.cs b/API/Helpers/UserParams.cs index a6aa2d304..344738f6d 100644 --- a/API/Helpers/UserParams.cs +++ b/API/Helpers/UserParams.cs @@ -2,6 +2,14 @@ { public class UserParams { - + private const int MaxPageSize = 50; + public int PageNumber { get; set; } = 1; + private int _pageSize = 10; + + public int PageSize + { + get => _pageSize; + set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value; + } } } \ No newline at end of file diff --git a/API/Interfaces/ILibraryRepository.cs b/API/Interfaces/ILibraryRepository.cs index 3955355f2..43e0db6e6 100644 --- a/API/Interfaces/ILibraryRepository.cs +++ b/API/Interfaces/ILibraryRepository.cs @@ -16,5 +16,6 @@ namespace API.Interfaces Task> GetLibraryDtosForUsernameAsync(string userName); Task> GetLibrariesAsync(); Task DeleteLibrary(int libraryId); + Task> GetLibrariesForUserIdAsync(int userId); } } \ No newline at end of file diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index 6b11ecb8f..92c4d2431 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -11,7 +11,19 @@ namespace API.Interfaces void Update(Series series); Task GetSeriesByNameAsync(string name); Series GetSeriesByName(string name); + /// + /// Adds user information like progress, ratings, etc + /// + /// + /// + /// Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId); + /// + /// Does not add user information like progress, ratings, etc. + /// + /// + /// + Task> SearchSeries(int[] libraryIds, string searchQuery); Task> GetSeriesForLibraryIdAsync(int libraryId); Task> GetVolumesDtoAsync(int seriesId, int userId); IEnumerable GetVolumes(int seriesId); diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 589bdc49c..4ae9f5723 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -52,6 +52,8 @@ namespace API.Services public void UpdateMetadata(Series series, bool forceUpdate) { + // TODO: this doesn't actually invoke finding a new cover. Also all these should be groupped ideally so we limit + // disk I/O to one method. if (series == null) return; if (ShouldFindCoverImage(series.CoverImage, forceUpdate)) {