Library Settings Modal + New Library Settings (#1660)

* Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed want to read button on series detail not performing the correct action

* Started the library settings. Added ability to update a cover image for a library.

Updated backup db to also copy reading list (and now library) cover images.

* Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library.

* Fixed a missing update event in backend when updating a library.

* Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid.

* Trim library names before you check anything

* General code cleanup

* Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries.

Refactored some code to streamline perf in some flows.

* Removed old components replaced with new modal

* Code smells

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2022-11-18 09:38:32 -06:00 committed by GitHub
parent 48b15e564d
commit 73d77e6264
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 2530 additions and 276 deletions

View file

@ -247,7 +247,6 @@ public class AccountController : BaseApiController
[HttpGet("roles")]
public ActionResult<IList<string>> GetRoles()
{
// TODO: This should be moved to ServerController
return typeof(PolicyConstants)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(string))

View file

@ -41,6 +41,22 @@ public class ImageController : BaseApiController
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
}
/// <summary>
/// Returns cover image for Library
/// </summary>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpGet("library-cover")]
[ResponseCache(CacheProfileName = "Images")]
public async Task<ActionResult> GetLibraryCoverImage(int libraryId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.LibraryRepository.GetLibraryCoverImageAsync(libraryId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
}
/// <summary>
/// Returns cover image for Volume
/// </summary>

View file

@ -295,35 +295,62 @@ public class LibraryController : BaseApiController
}
}
/// <summary>
/// Checks if the library name exists or not
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("name-exists")]
public async Task<ActionResult<bool>> IsLibraryNameValid(string name)
{
return Ok(await _unitOfWork.LibraryRepository.LibraryExists(name.Trim()));
}
/// <summary>
/// Updates an existing Library with new name, folders, and/or type.
/// </summary>
/// <remarks>Any folder or type change will invoke a scan.</remarks>
/// <param name="libraryForUserDto"></param>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateLibrary(UpdateLibraryDto libraryForUserDto)
public async Task<ActionResult> UpdateLibrary(UpdateLibraryDto dto)
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id, LibraryIncludes.Folders);
var newName = dto.Name.Trim();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.Id, LibraryIncludes.Folders);
if (await _unitOfWork.LibraryRepository.LibraryExists(newName) && !library.Name.Equals(newName))
return BadRequest("Library name already exists");
var originalFolders = library.Folders.Select(x => x.Path).ToList();
library.Name = libraryForUserDto.Name;
library.Folders = libraryForUserDto.Folders.Select(s => new FolderPath() {Path = s}).ToList();
library.Name = newName;
library.Folders = dto.Folders.Select(s => new FolderPath() {Path = s}).ToList();
var typeUpdate = library.Type != libraryForUserDto.Type;
library.Type = libraryForUserDto.Type;
var typeUpdate = library.Type != dto.Type;
var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching;
library.Type = dto.Type;
library.FolderWatching = dto.FolderWatching;
library.IncludeInDashboard = dto.IncludeInDashboard;
library.IncludeInRecommended = dto.IncludeInRecommended;
library.IncludeInSearch = dto.IncludeInSearch;
_unitOfWork.LibraryRepository.Update(library);
if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library.");
if (originalFolders.Count != libraryForUserDto.Folders.Count() || typeUpdate)
if (originalFolders.Count != dto.Folders.Count() || typeUpdate)
{
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
}
if (folderWatchingUpdate)
{
await _libraryWatcher.RestartWatching();
}
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "update"), false);
return Ok();
}

View file

@ -100,7 +100,6 @@ public class ReaderController : BaseApiController
try
{
// TODO: This code is very generic and repeated, see if we can refactor into a common method
var path = _cacheService.GetCachedPagePath(chapter, page);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache.");
var format = Path.GetExtension(path).Replace(".", "");

View file

@ -2,6 +2,7 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Search;
using API.Extensions;
@ -50,17 +51,16 @@ public class SearchController : BaseApiController
[HttpGet("search")]
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
{
queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty);
queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// Get libraries user has access to
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin,
libraries, queryString);
return Ok(series);
}

View file

@ -179,8 +179,6 @@ public class ServerController : BaseApiController
LastExecution = dto.LastExecution,
});
// For now, let's just do something simple
//var enqueuedJobs = JobStorage.Current.GetMonitoringApi().EnqueuedJobs("default", 0, int.MaxValue);
return Ok(recurringJobs);
}

View file

@ -266,6 +266,63 @@ public class UploadController : BaseApiController
return BadRequest("Unable to save cover image to Chapter");
}
/// <summary>
/// Replaces library cover image with a base64 encoded image. If empty string passed, will reset to null.
/// </summary>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)]
[HttpPost("library")]
public async Task<ActionResult> UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto)
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(uploadFileDto.Id);
if (library == null) return BadRequest("This library does not exist");
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
library.CoverImage = null;
_unitOfWork.LibraryRepository.Update(library);
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(library.Id, MessageFactoryEntityTypes.Library), false);
}
return Ok();
}
try
{
var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}");
if (!string.IsNullOrEmpty(filePath))
{
library.CoverImage = filePath;
_unitOfWork.LibraryRepository.Update(library);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(library.Id, MessageFactoryEntityTypes.Library), false);
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Library {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest("Unable to save cover image to Library");
}
/// <summary>
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
/// </summary>

View file

@ -13,5 +13,25 @@ public class LibraryDto
/// </summary>
public DateTime LastScanned { get; init; }
public LibraryType Type { get; init; }
/// <summary>
/// An optional Cover Image or null
/// </summary>
public string CoverImage { get; init; }
/// <summary>
/// If Folder Watching is enabled for this library
/// </summary>
public bool FolderWatching { get; set; } = true;
/// <summary>
/// Include Library series on Dashboard Streams
/// </summary>
public bool IncludeInDashboard { get; set; } = true;
/// <summary>
/// Include Library series on Recommended Streams
/// </summary>
public bool IncludeInRecommended { get; set; } = true;
/// <summary>
/// Include library series in Search
/// </summary>
public bool IncludeInSearch { get; set; } = true;
public ICollection<string> Folders { get; init; }
}

View file

@ -9,4 +9,9 @@ public class UpdateLibraryDto
public string Name { get; init; }
public LibraryType Type { get; set; }
public IEnumerable<string> Folders { get; init; }
public bool FolderWatching { get; init; }
public bool IncludeInDashboard { get; init; }
public bool IncludeInRecommended { get; init; }
public bool IncludeInSearch { get; init; }
}

View file

@ -89,6 +89,20 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<AppUserPreferences>()
.Property(b => b.GlobalPageLayoutMode)
.HasDefaultValue(PageLayoutMode.Cards);
builder.Entity<Library>()
.Property(b => b.FolderWatching)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.IncludeInDashboard)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.IncludeInRecommended)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.IncludeInSearch)
.HasDefaultValue(true);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class ExtendedLibrarySettings : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "FolderWatching",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "IncludeInDashboard",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "IncludeInRecommended",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "IncludeInSearch",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FolderWatching",
table: "Library");
migrationBuilder.DropColumn(
name: "IncludeInDashboard",
table: "Library");
migrationBuilder.DropColumn(
name: "IncludeInRecommended",
table: "Library");
migrationBuilder.DropColumn(
name: "IncludeInSearch",
table: "Library");
}
}
}

View file

@ -545,6 +545,26 @@ namespace API.Data.Migrations
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("FolderWatching")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IncludeInDashboard")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IncludeInRecommended")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IncludeInSearch")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");

View file

@ -9,6 +9,7 @@ using API.DTOs.JumpBar;
using API.DTOs.Metadata;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Kavita.Common.Extensions;
@ -38,7 +39,7 @@ public interface ILibraryRepository
Task<IEnumerable<Library>> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None);
Task<bool> DeleteLibrary(int libraryId);
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
Task<IEnumerable<int>> GetLibraryIdsForUserIdAsync(int userId);
IEnumerable<int> GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None);
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IEnumerable<int> libraryIds, LibraryIncludes includes = LibraryIncludes.None);
Task<int> GetTotalFiles();
@ -48,7 +49,8 @@ public interface ILibraryRepository
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync();
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
Task<bool> DoAnySeriesFoldersMatch(IEnumerable<string> folders);
Library GetLibraryByFolder(string folder);
Task<string> GetLibraryCoverImageAsync(int libraryId);
Task<IList<string>> GetAllCoverImagesAsync();
}
public class LibraryRepository : ILibraryRepository
@ -126,12 +128,13 @@ public class LibraryRepository : ILibraryRepository
.ToListAsync();
}
public async Task<IEnumerable<int>> GetLibraryIdsForUserIdAsync(int userId)
public IEnumerable<int> GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None)
{
return await _context.Library
return _context.Library
.IsRestricted(queryContext)
.Where(l => l.AppUsers.Select(ap => ap.Id).Contains(userId))
.Select(l => l.Id)
.ToListAsync();
.AsEnumerable();
}
public async Task<LibraryType> GetLibraryTypeAsync(int libraryId)
@ -377,12 +380,21 @@ public class LibraryRepository : ILibraryRepository
return await _context.Series.AnyAsync(s => normalized.Contains(s.FolderPath));
}
public Library? GetLibraryByFolder(string folder)
public Task<string> GetLibraryCoverImageAsync(int libraryId)
{
var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder);
return _context.Library
.Include(l => l.Folders)
.AsSplitQuery()
.SingleOrDefault(l => l.Folders.Select(f => f.Path).Contains(normalized));
.Where(l => l.Id == libraryId)
.Select(l => l.CoverImage)
.SingleOrDefaultAsync();
}
public async Task<IList<string>> GetAllCoverImagesAsync()
{
return await _context.ReadingList
.Select(t => t.CoverImage)
.Where(t => !string.IsNullOrEmpty(t))
.AsNoTracking()
.ToListAsync();
}
}

View file

@ -37,6 +37,18 @@ public enum SeriesIncludes
Library = 16,
}
/// <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);
@ -62,7 +74,7 @@ public interface ISeriesRepository
/// <param name="libraryIds"></param>
/// <param name="searchQuery"></param>
/// <returns></returns>
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery);
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);
@ -257,7 +269,7 @@ public class SeriesRepository : ISeriesRepository
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter);
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.None);
var retSeries = query
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
@ -267,13 +279,14 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
private async Task<List<int>> GetUserLibraries(int libraryId, int userId)
private async Task<List<int>> GetUserLibrariesForFilteredQuery(int libraryId, int userId, QueryContext queryContext)
{
if (libraryId == 0)
{
return await _context.Library
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(user => user.Id == userId))
.IsRestricted(queryContext)
.AsNoTracking()
.AsSplitQuery()
.Select(library => library.Id)
@ -286,7 +299,7 @@ public class SeriesRepository : ISeriesRepository
};
}
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery)
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery)
{
const int maxRecords = 15;
var result = new SearchResultGroupDto();
@ -302,6 +315,7 @@ public class SeriesRepository : ISeriesRepository
result.Libraries = await _context.Library
.Where(l => libraryIds.Contains(l.Id))
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.IsRestricted(QueryContext.Search)
.OrderBy(l => l.Name)
.AsSplitQuery()
.Take(maxRecords)
@ -549,7 +563,7 @@ public class SeriesRepository : ISeriesRepository
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter);
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.Dashboard);
var retSeries = query
.OrderByDescending(s => s.Created)
@ -658,7 +672,7 @@ public class SeriesRepository : ISeriesRepository
var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30);
var cutoffLastAddedPoint = DateTime.Now - TimeSpan.FromDays(7);
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
@ -686,9 +700,9 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter)
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, QueryContext queryContext)
{
var userLibraries = await GetUserLibraries(libraryId, userId);
var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, queryContext);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
@ -762,7 +776,7 @@ public class SeriesRepository : ISeriesRepository
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable<Series> sQuery)
{
var userLibraries = await GetUserLibraries(libraryId, userId);
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,
@ -1059,7 +1073,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
@ -1086,7 +1100,7 @@ public class SeriesRepository : ISeriesRepository
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
.Where(s => usersSeriesIds.Contains(s.SeriesId))
@ -1105,7 +1119,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId)
{
var libraryIds = GetLibraryIdsForUser(userId);
var libraryIds = GetLibraryIdsForUser(userId, 0, QueryContext.Search);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.MangaFile
@ -1264,7 +1278,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithHighRating = _context.AppUserRating
.Where(s => usersSeriesIds.Contains(s.SeriesId) && s.Rating > 4)
@ -1285,7 +1299,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetQuickReads(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
.Where(s => usersSeriesIds.Contains(s.SeriesId))
@ -1311,7 +1325,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
.Where(s => usersSeriesIds.Contains(s.SeriesId))
@ -1341,21 +1355,27 @@ public class SeriesRepository : ISeriesRepository
/// </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)
private IQueryable<int> GetLibraryIdsForUser(int userId, int libraryId = 0, QueryContext queryContext = QueryContext.None)
{
var query = _context.AppUser
var user = _context.AppUser
.AsSplitQuery()
.AsNoTracking()
.Where(u => u.Id == userId);
.Where(u => u.Id == userId)
.AsSingleQuery();
if (libraryId == 0)
{
return query.SelectMany(l => l.Libraries.Select(lib => lib.Id));
return user.SelectMany(l => l.Libraries)
.IsRestricted(queryContext)
.Select(lib => lib.Id);
}
return query.SelectMany(l =>
l.Libraries.Where(lib => lib.Id == libraryId).Select(lib => lib.Id));
return user.SelectMany(l => l.Libraries)
.Where(lib => lib.Id == libraryId)
.IsRestricted(queryContext)
.Select(lib => lib.Id);
}
public async Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId)
@ -1430,8 +1450,9 @@ public class SeriesRepository : ISeriesRepository
{
var libraryIds = await _context.AppUser
.Where(u => u.Id == userId)
.SelectMany(u => u.Libraries.Select(l => new {LibraryId = l.Id, LibraryType = l.Type}))
.Select(l => l.LibraryId)
.SelectMany(u => u.Libraries)
.Where(l => l.IncludeInDashboard)
.Select(l => l.Id)
.ToListAsync();
var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12);

View file

@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using API.Entities.Enums;
using API.Entities.Interfaces;
@ -11,12 +9,24 @@ public class Library : IEntityDate
{
public int Id { get; set; }
public string Name { get; set; }
/// <summary>
/// This is not used, but planned once we build out a Library detail page
/// </summary>
[Obsolete("This has never been coded for. Likely we can remove it.")]
public string CoverImage { get; set; }
public LibraryType Type { get; set; }
/// <summary>
/// If Folder Watching is enabled for this library
/// </summary>
public bool FolderWatching { get; set; } = true;
/// <summary>
/// Include Library series on Dashboard Streams
/// </summary>
public bool IncludeInDashboard { get; set; } = true;
/// <summary>
/// Include Library series on Recommended Streams
/// </summary>
public bool IncludeInRecommended { get; set; } = true;
/// <summary>
/// Include library series in Search
/// </summary>
public bool IncludeInSearch { get; set; } = true;
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
/// <summary>
@ -27,4 +37,5 @@ public class Library : IEntityDate
public ICollection<FolderPath> Folders { get; set; }
public ICollection<AppUser> AppUsers { get; set; }
public ICollection<Series> Series { get; set; }
}

View file

@ -158,4 +158,32 @@ public static class QueryableExtensions
return query.AsSplitQuery();
}
/// <summary>
/// Applies restriction based on if the Library has restrictions (like include in search)
/// </summary>
/// <param name="query"></param>
/// <param name="context"></param>
/// <returns></returns>
public static IQueryable<Library> IsRestricted(this IQueryable<Library> query, QueryContext context)
{
if (context.HasFlag(QueryContext.None)) return query;
if (context.HasFlag(QueryContext.Dashboard))
{
query = query.Where(l => l.IncludeInDashboard);
}
if (context.HasFlag(QueryContext.Recommended))
{
query = query.Where(l => l.IncludeInRecommended);
}
if (context.HasFlag(QueryContext.Search))
{
query = query.Where(l => l.IncludeInSearch);
}
return query;
}
}

View file

@ -86,7 +86,7 @@ public class Program
{
await MigrateSeriesRelationsExport.Migrate(context, logger);
}
catch (Exception ex)
catch (Exception)
{
// If fresh install, could fail and we should just carry on as it's not applicable
}

View file

@ -167,6 +167,16 @@ public class ImageService : IImageService
return $"v{volumeId}_c{chapterId}";
}
/// <summary>
/// Returns the name format for a library cover image
/// </summary>
/// <param name="libraryId"></param>
/// <returns></returns>
public static string GetLibraryFormat(int libraryId)
{
return $"l{libraryId}";
}
/// <summary>
/// Returns the name format for a series cover image
/// </summary>

View file

@ -473,7 +473,7 @@ public class SeriesService : ISeriesService
public async Task<SeriesDetailDto> GetSeriesDetail(int seriesId, int userId)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var libraryIds = (await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId));
var libraryIds = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId);
if (!libraryIds.Contains(series.LibraryId))
throw new UnauthorizedAccessException("User does not have access to the library this series belongs to");

View file

@ -162,6 +162,14 @@ public class BackupService : IBackupService
var chapterImages = await _unitOfWork.ChapterRepository.GetCoverImagesForLockedChaptersAsync();
_directoryService.CopyFilesToDirectory(
chapterImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir);
var libraryImages = await _unitOfWork.LibraryRepository.GetAllCoverImagesAsync();
_directoryService.CopyFilesToDirectory(
libraryImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir);
var readingListImages = await _unitOfWork.ReadingListRepository.GetAllCoverImagesAsync();
_directoryService.CopyFilesToDirectory(
readingListImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir);
}
catch (IOException)
{

View file

@ -77,6 +77,7 @@ public class LibraryWatcher : ILibraryWatcher
_logger.LogInformation("[LibraryWatcher] Starting file watchers");
var libraryFolders = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync())
.Where(l => l.FolderWatching)
.SelectMany(l => l.Folders)
.Distinct()
.Select(Parser.Parser.NormalizePath)

View file

@ -1011,6 +1011,17 @@ public static class Parser
return string.IsNullOrEmpty(author) ? string.Empty : author.Trim();
}
/// <summary>
/// Cleans user query string input
/// </summary>
/// <param name="query"></param>
/// <returns></returns>
public static string CleanQuery(string query)
{
return Uri.UnescapeDataString(query).Trim().Replace(@"%", string.Empty)
.Replace(":", string.Empty);
}
/// <summary>
/// Normalizes the slashes in a path to be <see cref="Path.AltDirectorySeparatorChar"/>
/// </summary>

View file

@ -10,6 +10,7 @@ namespace API.SignalR;
public static class MessageFactoryEntityTypes
{
public const string Library = "library";
public const string Series = "series";
public const string Volume = "volume";
public const string Chapter = "chapter";