Browse by Genre/Tag/Person with new metadata system for People (#3835)
Co-authored-by: Stepan Goremykin <s.goremykin@proton.me> Co-authored-by: goremykin <goremukin@gmail.com> Co-authored-by: Christopher <39032787+MrRobotjs@users.noreply.github.com> Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
parent
00c4712fc3
commit
c52ed1f65d
147 changed files with 6612 additions and 958 deletions
|
|
@ -97,9 +97,9 @@
|
|||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.3" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="9.0.5" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="22.0.14" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="9.0.4" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.3.4" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ using System.Threading.Tasks;
|
|||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.SeriesDetail;
|
||||
|
|
@ -46,6 +48,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of Genres with counts for counts when Genre is on Series/Chapter
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpPost("genres-with-counts")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)]
|
||||
public async Task<ActionResult<PagedList<BrowseGenreDto>>> GetBrowseGenres(UserParams? userParams = null)
|
||||
{
|
||||
userParams ??= UserParams.Default;
|
||||
|
||||
var list = await unitOfWork.GenreRepository.GetBrowseableGenre(User.GetUserId(), userParams);
|
||||
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
|
||||
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches people from the instance by role
|
||||
/// </summary>
|
||||
|
|
@ -95,6 +113,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of Tags with counts for counts when Tag is on Series/Chapter
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpPost("tags-with-counts")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)]
|
||||
public async Task<ActionResult<PagedList<BrowseTagDto>>> GetBrowseTags(UserParams? userParams = null)
|
||||
{
|
||||
userParams ??= UserParams.Default;
|
||||
|
||||
var list = await unitOfWork.TagRepository.GetBrowseableTag(User.GetUserId(), userParams);
|
||||
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
|
||||
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all age ratings from the instance
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ using System.Threading.Tasks;
|
|||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.DTOs.Metadata.Browse.Requests;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
|
|
@ -77,11 +80,13 @@ public class PersonController : BaseApiController
|
|||
/// <param name="userParams"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("all")]
|
||||
public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetAuthorsForBrowse([FromQuery] UserParams? userParams)
|
||||
public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetPeopleForBrowse(BrowsePersonFilterDto filter, [FromQuery] UserParams? userParams)
|
||||
{
|
||||
userParams ??= UserParams.Default;
|
||||
var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams);
|
||||
|
||||
var list = await _unitOfWork.PersonRepository.GetBrowsePersonDtos(User.GetUserId(), filter, userParams);
|
||||
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
|
||||
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +117,7 @@ public class PersonController : BaseApiController
|
|||
|
||||
|
||||
person.Name = dto.Name?.Trim();
|
||||
person.NormalizedName = person.Name.ToNormalized();
|
||||
person.Description = dto.Description ?? string.Empty;
|
||||
person.CoverImageLocked = dto.CoverImageLocked;
|
||||
|
||||
|
|
|
|||
8
API/DTOs/Filtering/PersonSortField.cs
Normal file
8
API/DTOs/Filtering/PersonSortField.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace API.DTOs.Filtering;
|
||||
|
||||
public enum PersonSortField
|
||||
{
|
||||
Name = 1,
|
||||
SeriesCount = 2,
|
||||
ChapterCount = 3
|
||||
}
|
||||
|
|
@ -8,3 +8,12 @@ public sealed record SortOptions
|
|||
public SortField SortField { get; set; }
|
||||
public bool IsAscending { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All Sorting Options for a query related to Person Entity
|
||||
/// </summary>
|
||||
public sealed record PersonSortOptions
|
||||
{
|
||||
public PersonSortField SortField { get; set; }
|
||||
public bool IsAscending { get; set; } = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,5 +56,12 @@ public enum FilterField
|
|||
/// Last time User Read
|
||||
/// </summary>
|
||||
ReadLast = 32,
|
||||
|
||||
}
|
||||
|
||||
public enum PersonFilterField
|
||||
{
|
||||
Role = 1,
|
||||
Name = 2,
|
||||
SeriesCount = 3,
|
||||
ChapterCount = 4,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
namespace API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata.Browse.Requests;
|
||||
|
||||
namespace API.DTOs.Filtering.v2;
|
||||
|
||||
public sealed record FilterStatementDto
|
||||
{
|
||||
|
|
@ -6,3 +8,10 @@ public sealed record FilterStatementDto
|
|||
public FilterField Field { get; set; }
|
||||
public string Value { get; set; }
|
||||
}
|
||||
|
||||
public sealed record PersonFilterStatementDto
|
||||
{
|
||||
public FilterComparison Comparison { get; set; }
|
||||
public PersonFilterField Field { get; set; }
|
||||
public string Value { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public sealed record FilterV2Dto
|
|||
/// The name of the filter
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
public ICollection<FilterStatementDto> Statements { get; set; } = new List<FilterStatementDto>();
|
||||
public ICollection<FilterStatementDto> Statements { get; set; } = [];
|
||||
public FilterCombination Combination { get; set; } = FilterCombination.And;
|
||||
public SortOptions? SortOptions { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -15,5 +15,9 @@ public enum MatchStateOption
|
|||
public sealed record ManageMatchFilterDto
|
||||
{
|
||||
public MatchStateOption MatchStateOption { get; set; } = MatchStateOption.All;
|
||||
/// <summary>
|
||||
/// Library Type in int form. -1 indicates to ignore the field.
|
||||
/// </summary>
|
||||
public int LibraryType { get; set; } = -1;
|
||||
public string SearchTerm { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
|
|||
13
API/DTOs/Metadata/Browse/BrowseGenreDto.cs
Normal file
13
API/DTOs/Metadata/Browse/BrowseGenreDto.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace API.DTOs.Metadata.Browse;
|
||||
|
||||
public sealed record BrowseGenreDto : GenreTagDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of Series this Entity is on
|
||||
/// </summary>
|
||||
public int SeriesCount { get; set; }
|
||||
/// <summary>
|
||||
/// Number of Chapters this Entity is on
|
||||
/// </summary>
|
||||
public int ChapterCount { get; set; }
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using API.DTOs.Person;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.Metadata.Browse;
|
||||
|
||||
/// <summary>
|
||||
/// Used to browse writers and click in to see their series
|
||||
|
|
@ -12,7 +12,7 @@ public class BrowsePersonDto : PersonDto
|
|||
/// </summary>
|
||||
public int SeriesCount { get; set; }
|
||||
/// <summary>
|
||||
/// Number or Issues this Person is the Writer for
|
||||
/// Number of Issues this Person is the Writer for
|
||||
/// </summary>
|
||||
public int IssueCount { get; set; }
|
||||
public int ChapterCount { get; set; }
|
||||
}
|
||||
13
API/DTOs/Metadata/Browse/BrowseTagDto.cs
Normal file
13
API/DTOs/Metadata/Browse/BrowseTagDto.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace API.DTOs.Metadata.Browse;
|
||||
|
||||
public sealed record BrowseTagDto : TagDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of Series this Entity is on
|
||||
/// </summary>
|
||||
public int SeriesCount { get; set; }
|
||||
/// <summary>
|
||||
/// Number of Chapters this Entity is on
|
||||
/// </summary>
|
||||
public int ChapterCount { get; set; }
|
||||
}
|
||||
27
API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs
Normal file
27
API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Collections.Generic;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Metadata.Browse.Requests;
|
||||
#nullable enable
|
||||
|
||||
public sealed record BrowsePersonFilterDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Not used - For parity with Series Filter
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Not used - For parity with Series Filter
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
public ICollection<PersonFilterStatementDto> Statements { get; set; } = [];
|
||||
public FilterCombination Combination { get; set; } = FilterCombination.And;
|
||||
public PersonSortOptions? SortOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Limit the number of rows returned. Defaults to not applying a limit (aka 0)
|
||||
/// </summary>
|
||||
public int LimitTo { get; set; } = 0;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
namespace API.DTOs.Metadata;
|
||||
|
||||
public sealed record GenreTagDto
|
||||
public record GenreTagDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Title { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
namespace API.DTOs.Metadata;
|
||||
|
||||
public sealed record TagDto
|
||||
public record TagDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Title { get; set; }
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ public sealed record ReadingListDto : IHasCoverImage
|
|||
/// </summary>
|
||||
public required AgeRating AgeRating { get; set; } = AgeRating.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Username of the User that owns (in the case of a promoted list)
|
||||
/// </summary>
|
||||
public string OwnerUserName { get; set; }
|
||||
|
||||
public void ResetColorScape()
|
||||
{
|
||||
PrimaryColor = string.Empty;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ public sealed record UserReadingProfileDto
|
|||
/// <inheritdoc cref="AppUserReadingProfile.WidthOverride"/>
|
||||
public int? WidthOverride { get; set; }
|
||||
|
||||
/// <inheritdoc cref="AppUserReadingProfile.DisableWidthOverride"/>
|
||||
public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never;
|
||||
|
||||
#endregion
|
||||
|
||||
#region EpubReader
|
||||
|
|
|
|||
3701
API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs
generated
Normal file
3701
API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AppUserReadingProfileDisableWidthOverrideBreakPoint : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DisableWidthOverride",
|
||||
table: "AppUserReadingProfiles",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DisableWidthOverride",
|
||||
table: "AppUserReadingProfiles");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -665,6 +665,9 @@ namespace API.Data.Migrations
|
|||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("Dark");
|
||||
|
||||
b.Property<int>("DisableWidthOverride")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EmulateBook")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
|
@ -704,7 +707,7 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("ScalingOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.PrimitiveCollection<string>("SeriesIds")
|
||||
b.Property<string>("SeriesIds")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ShowScreenHints")
|
||||
|
|
|
|||
|
|
@ -108,14 +108,17 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
|
|||
|
||||
public async Task<bool> NeedsDataRefresh(int seriesId)
|
||||
{
|
||||
// TODO: Add unit test
|
||||
var row = await _context.ExternalSeriesMetadata
|
||||
.Where(s => s.SeriesId == seriesId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return row == null || row.ValidUntilUtc <= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public async Task<SeriesDetailPlusDto?> GetSeriesDetailPlusDto(int seriesId)
|
||||
{
|
||||
// TODO: Add unit test
|
||||
var seriesDetailDto = await _context.ExternalSeriesMetadata
|
||||
.Where(m => m.SeriesId == seriesId)
|
||||
.Include(m => m.ExternalRatings)
|
||||
|
|
@ -144,7 +147,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
|
|||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
IEnumerable<UserReviewDto> reviews = new List<UserReviewDto>();
|
||||
IEnumerable<UserReviewDto> reviews = [];
|
||||
if (seriesDetailDto.ExternalReviews != null && seriesDetailDto.ExternalReviews.Any())
|
||||
{
|
||||
reviews = seriesDetailDto.ExternalReviews
|
||||
|
|
@ -231,6 +234,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
|
|||
.Include(s => s.ExternalSeriesMetadata)
|
||||
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
|
||||
.Where(s => s.Library.AllowMetadataMatching)
|
||||
.WhereIf(filter.LibraryType >= 0, s => s.Library.Type == (LibraryType) filter.LibraryType)
|
||||
.FilterMatchState(filter.MatchStateOption)
|
||||
.OrderBy(s => s.NormalizedName)
|
||||
.ProjectTo<ManageMatchSeriesDto>(_mapper.ConfigurationProvider)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Helpers;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
|
|
@ -27,6 +29,7 @@ public interface IGenreRepository
|
|||
Task<GenreTagDto> GetRandomGenre();
|
||||
Task<GenreTagDto> GetGenreById(int id);
|
||||
Task<List<string>> GetAllGenresNotInListAsync(ICollection<string> genreNames);
|
||||
Task<PagedList<BrowseGenreDto>> GetBrowseableGenre(int userId, UserParams userParams);
|
||||
}
|
||||
|
||||
public class GenreRepository : IGenreRepository
|
||||
|
|
@ -165,4 +168,28 @@ public class GenreRepository : IGenreRepository
|
|||
// Return the original non-normalized genres for the missing ones
|
||||
return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList();
|
||||
}
|
||||
|
||||
public async Task<PagedList<BrowseGenreDto>> GetBrowseableGenre(int userId, UserParams userParams)
|
||||
{
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
var query = _context.Genre
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.Select(g => new BrowseGenreDto
|
||||
{
|
||||
Id = g.Id,
|
||||
Title = g.Title,
|
||||
SeriesCount = g.SeriesMetadatas
|
||||
.Select(sm => sm.Id)
|
||||
.Distinct()
|
||||
.Count(),
|
||||
ChapterCount = g.Chapters
|
||||
.Select(ch => ch.Id)
|
||||
.Distinct()
|
||||
.Count()
|
||||
})
|
||||
.OrderBy(g => g.Title);
|
||||
|
||||
return await PagedList<BrowseGenreDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,19 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Misc;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.DTOs.Metadata.Browse.Requests;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Extensions.QueryExtensions.Filtering;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Converters;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -45,7 +51,7 @@ public interface IPersonRepository
|
|||
Task<string?> GetCoverImageAsync(int personId);
|
||||
Task<string?> GetCoverImageByNameAsync(string name);
|
||||
Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId);
|
||||
Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams);
|
||||
Task<PagedList<BrowsePersonDto>> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams);
|
||||
Task<Person?> GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None);
|
||||
Task<PersonDto?> GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases);
|
||||
/// <summary>
|
||||
|
|
@ -194,36 +200,82 @@ public class PersonRepository : IPersonRepository
|
|||
return chapterRoles.Union(seriesRoles).Distinct();
|
||||
}
|
||||
|
||||
public async Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams)
|
||||
public async Task<PagedList<BrowsePersonDto>> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams)
|
||||
{
|
||||
List<PersonRole> roles = [PersonRole.Writer, PersonRole.CoverArtist];
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
var query = _context.Person
|
||||
.Where(p => p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role)))
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.Select(p => new BrowsePersonDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
CoverImage = p.CoverImage,
|
||||
SeriesCount = p.SeriesMetadataPeople
|
||||
.Where(smp => roles.Contains(smp.Role))
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
.Distinct()
|
||||
.Count(),
|
||||
IssueCount = p.ChapterPeople
|
||||
.Where(cp => roles.Contains(cp.Role))
|
||||
.Select(cp => cp.Chapter.Id)
|
||||
.Distinct()
|
||||
.Count()
|
||||
})
|
||||
.OrderBy(p => p.Name);
|
||||
var query = CreateFilteredPersonQueryable(userId, filter, ageRating);
|
||||
|
||||
return await PagedList<BrowsePersonDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
private IQueryable<BrowsePersonDto> CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating)
|
||||
{
|
||||
var query = _context.Person.AsNoTracking();
|
||||
|
||||
// Apply filtering based on statements
|
||||
query = BuildPersonFilterQuery(userId, filter, query);
|
||||
|
||||
// Apply age restriction
|
||||
query = query.RestrictAgainstAgeRestriction(ageRating);
|
||||
|
||||
// Apply sorting and limiting
|
||||
var sortedQuery = query.SortBy(filter.SortOptions);
|
||||
|
||||
var limitedQuery = ApplyPersonLimit(sortedQuery, filter.LimitTo);
|
||||
|
||||
// Project to DTO
|
||||
var projectedQuery = limitedQuery.Select(p => new BrowsePersonDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
CoverImage = p.CoverImage,
|
||||
SeriesCount = p.SeriesMetadataPeople
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
.Distinct()
|
||||
.Count(),
|
||||
ChapterCount = p.ChapterPeople
|
||||
.Select(cp => cp.Chapter.Id)
|
||||
.Distinct()
|
||||
.Count()
|
||||
});
|
||||
|
||||
return projectedQuery;
|
||||
}
|
||||
|
||||
private static IQueryable<Person> BuildPersonFilterQuery(int userId, BrowsePersonFilterDto filterDto, IQueryable<Person> query)
|
||||
{
|
||||
if (filterDto.Statements == null || filterDto.Statements.Count == 0) return query;
|
||||
|
||||
var queries = filterDto.Statements
|
||||
.Select(statement => BuildPersonFilterGroup(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<Person> BuildPersonFilterGroup(int userId, PersonFilterStatementDto statement, IQueryable<Person> query)
|
||||
{
|
||||
var value = PersonFilterFieldValueConverter.ConvertValue(statement.Field, statement.Value);
|
||||
|
||||
return statement.Field switch
|
||||
{
|
||||
PersonFilterField.Name => query.HasPersonName(true, statement.Comparison, (string)value),
|
||||
PersonFilterField.Role => query.HasPersonRole(true, statement.Comparison, (IList<PersonRole>)value),
|
||||
PersonFilterField.SeriesCount => query.HasPersonSeriesCount(true, statement.Comparison, (int)value),
|
||||
PersonFilterField.ChapterCount => query.HasPersonChapterCount(true, statement.Comparison, (int)value),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}")
|
||||
};
|
||||
}
|
||||
|
||||
private static IQueryable<Person> ApplyPersonLimit(IQueryable<Person> query, int limit)
|
||||
{
|
||||
return limit <= 0 ? query : query.Take(limit);
|
||||
}
|
||||
|
||||
public async Task<Person?> GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None)
|
||||
{
|
||||
return await _context.Person.Where(p => p.Id == personId)
|
||||
|
|
|
|||
|
|
@ -735,6 +735,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
{
|
||||
return await _context.Series
|
||||
.Where(s => s.Id == seriesId)
|
||||
.Include(s => s.ExternalSeriesMetadata)
|
||||
.Select(series => new PlusSeriesRequestDto()
|
||||
{
|
||||
MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
|
||||
|
|
@ -744,6 +745,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
ScrobblingService.AniListWeblinkWebsite),
|
||||
MalId = ScrobblingService.ExtractId<long?>(series.Metadata.WebLinks,
|
||||
ScrobblingService.MalWeblinkWebsite),
|
||||
CbrId = series.ExternalSeriesMetadata.CbrId,
|
||||
GoogleBooksId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks,
|
||||
ScrobblingService.GoogleBooksWeblinkWebsite),
|
||||
MangaDexId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks,
|
||||
|
|
@ -1088,8 +1090,6 @@ public class SeriesRepository : ISeriesRepository
|
|||
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);
|
||||
|
||||
|
|
@ -1290,7 +1290,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId),
|
||||
FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId),
|
||||
FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value),
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}")
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Helpers;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
|
|
@ -23,6 +25,7 @@ public interface ITagRepository
|
|||
Task RemoveAllTagNoLongerAssociated();
|
||||
Task<IList<TagDto>> GetAllTagDtosForLibrariesAsync(int userId, IList<int>? libraryIds = null);
|
||||
Task<List<string>> GetAllTagsNotInListAsync(ICollection<string> tags);
|
||||
Task<PagedList<BrowseTagDto>> GetBrowseableTag(int userId, UserParams userParams);
|
||||
}
|
||||
|
||||
public class TagRepository : ITagRepository
|
||||
|
|
@ -104,6 +107,30 @@ public class TagRepository : ITagRepository
|
|||
return missingTags.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList();
|
||||
}
|
||||
|
||||
public async Task<PagedList<BrowseTagDto>> GetBrowseableTag(int userId, UserParams userParams)
|
||||
{
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
var query = _context.Tag
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.Select(g => new BrowseTagDto
|
||||
{
|
||||
Id = g.Id,
|
||||
Title = g.Title,
|
||||
SeriesCount = g.SeriesMetadatas
|
||||
.Select(sm => sm.Id)
|
||||
.Distinct()
|
||||
.Count(),
|
||||
ChapterCount = g.Chapters
|
||||
.Select(ch => ch.Id)
|
||||
.Distinct()
|
||||
.Count()
|
||||
})
|
||||
.OrderBy(g => g.Title);
|
||||
|
||||
return await PagedList<BrowseTagDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<IList<Tag>> GetAllTagsAsync()
|
||||
{
|
||||
return await _context.Tag.ToListAsync();
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ public static class Seed
|
|||
new AppUserSideNavStream()
|
||||
{
|
||||
Name = "browse-authors",
|
||||
StreamType = SideNavStreamType.BrowseAuthors,
|
||||
StreamType = SideNavStreamType.BrowsePeople,
|
||||
Order = 6,
|
||||
IsProvided = true,
|
||||
Visible = true
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
public enum BreakPoint
|
||||
{
|
||||
[Description("Never")]
|
||||
Never = 0,
|
||||
[Description("Mobile")]
|
||||
Mobile = 1,
|
||||
[Description("Tablet")]
|
||||
Tablet = 2,
|
||||
[Description("Desktop")]
|
||||
Desktop = 3,
|
||||
}
|
||||
|
||||
public class AppUserReadingProfile
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
|
@ -72,6 +85,10 @@ public class AppUserReadingProfile
|
|||
/// Manga Reader Option: Optional fixed width override
|
||||
/// </summary>
|
||||
public int? WidthOverride { get; set; } = null;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Disable the width override if the screen is past the breakpoint
|
||||
/// </summary>
|
||||
public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never;
|
||||
|
||||
#endregion
|
||||
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@ public enum SideNavStreamType
|
|||
ExternalSource = 6,
|
||||
AllSeries = 7,
|
||||
WantToRead = 8,
|
||||
BrowseAuthors = 9
|
||||
BrowsePeople = 9
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
|||
using System.Text.RegularExpressions;
|
||||
using API.Data.Misc;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
|
||||
namespace API.Extensions;
|
||||
#nullable enable
|
||||
|
|
@ -42,4 +43,16 @@ public static class EnumerableExtensions
|
|||
|
||||
return q;
|
||||
}
|
||||
|
||||
public static IEnumerable<SeriesMetadata> RestrictAgainstAgeRestriction(this IEnumerable<SeriesMetadata> items, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return items;
|
||||
var q = items.Where(s => s.AgeRating <= restriction.AgeRating);
|
||||
if (!restriction.IncludeUnknowns)
|
||||
{
|
||||
return q.Where(s => s.AgeRating != AgeRating.Unknown);
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
136
API/Extensions/QueryExtensions/Filtering/PersonFilter.cs
Normal file
136
API/Extensions/QueryExtensions/Filtering/PersonFilter.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Person;
|
||||
using Kavita.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Extensions.QueryExtensions.Filtering;
|
||||
|
||||
public static class PersonFilter
|
||||
{
|
||||
public static IQueryable<Person> HasPersonName(this IQueryable<Person> queryable, bool condition,
|
||||
FilterComparison comparison, string queryString)
|
||||
{
|
||||
if (string.IsNullOrEmpty(queryString) || !condition) return queryable;
|
||||
|
||||
return comparison switch
|
||||
{
|
||||
FilterComparison.Equal => queryable.Where(p => p.Name.Equals(queryString)),
|
||||
FilterComparison.BeginsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"{queryString}%")),
|
||||
FilterComparison.EndsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}")),
|
||||
FilterComparison.Matches => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}%")),
|
||||
FilterComparison.NotEqual => queryable.Where(p => p.Name != queryString),
|
||||
FilterComparison.NotContains or FilterComparison.GreaterThan or FilterComparison.GreaterThanEqual
|
||||
or FilterComparison.LessThan or FilterComparison.LessThanEqual or FilterComparison.Contains
|
||||
or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast
|
||||
or FilterComparison.IsNotInLast or FilterComparison.MustContains
|
||||
or FilterComparison.IsEmpty =>
|
||||
throw new KavitaException($"{comparison} not applicable for Person.Name"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison,
|
||||
"Filter Comparison is not supported")
|
||||
};
|
||||
}
|
||||
public static IQueryable<Person> HasPersonRole(this IQueryable<Person> queryable, bool condition,
|
||||
FilterComparison comparison, IList<PersonRole> roles)
|
||||
{
|
||||
if (roles == null || roles.Count == 0 || !condition) return queryable;
|
||||
|
||||
return comparison switch
|
||||
{
|
||||
FilterComparison.Contains or FilterComparison.MustContains => queryable.Where(p =>
|
||||
p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) ||
|
||||
p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))),
|
||||
FilterComparison.NotContains => queryable.Where(p =>
|
||||
!p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) &&
|
||||
!p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))),
|
||||
FilterComparison.Equal or FilterComparison.NotEqual or FilterComparison.BeginsWith
|
||||
or FilterComparison.EndsWith or FilterComparison.Matches or FilterComparison.GreaterThan
|
||||
or FilterComparison.GreaterThanEqual or FilterComparison.LessThan or FilterComparison.LessThanEqual
|
||||
or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast
|
||||
or FilterComparison.IsNotInLast
|
||||
or FilterComparison.IsEmpty =>
|
||||
throw new KavitaException($"{comparison} not applicable for Person.Role"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison,
|
||||
"Filter Comparison is not supported")
|
||||
};
|
||||
}
|
||||
|
||||
public static IQueryable<Person> HasPersonSeriesCount(this IQueryable<Person> queryable, bool condition,
|
||||
FilterComparison comparison, int count)
|
||||
{
|
||||
if (!condition) return queryable;
|
||||
|
||||
return comparison switch
|
||||
{
|
||||
FilterComparison.Equal => queryable.Where(p => p.SeriesMetadataPeople
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
.Distinct()
|
||||
.Count() == count),
|
||||
FilterComparison.GreaterThan => queryable.Where(p => p.SeriesMetadataPeople
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
.Distinct()
|
||||
.Count() > count),
|
||||
FilterComparison.GreaterThanEqual => queryable.Where(p => p.SeriesMetadataPeople
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
.Distinct()
|
||||
.Count() >= count),
|
||||
FilterComparison.LessThan => queryable.Where(p => p.SeriesMetadataPeople
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
.Distinct()
|
||||
.Count() < count),
|
||||
FilterComparison.LessThanEqual => queryable.Where(p => p.SeriesMetadataPeople
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
.Distinct()
|
||||
.Count() <= count),
|
||||
FilterComparison.NotEqual => queryable.Where(p => p.SeriesMetadataPeople
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
.Distinct()
|
||||
.Count() != count),
|
||||
FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches
|
||||
or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore
|
||||
or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast
|
||||
or FilterComparison.MustContains
|
||||
or FilterComparison.IsEmpty => throw new KavitaException(
|
||||
$"{comparison} not applicable for Person.SeriesCount"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported")
|
||||
};
|
||||
}
|
||||
|
||||
public static IQueryable<Person> HasPersonChapterCount(this IQueryable<Person> queryable, bool condition,
|
||||
FilterComparison comparison, int count)
|
||||
{
|
||||
if (!condition) return queryable;
|
||||
|
||||
return comparison switch
|
||||
{
|
||||
FilterComparison.Equal => queryable.Where(p =>
|
||||
p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() == count),
|
||||
FilterComparison.GreaterThan => queryable.Where(p => p.ChapterPeople
|
||||
.Select(cp => cp.Chapter.Id)
|
||||
.Distinct()
|
||||
.Count() > count),
|
||||
FilterComparison.GreaterThanEqual => queryable.Where(p => p.ChapterPeople
|
||||
.Select(cp => cp.Chapter.Id)
|
||||
.Distinct()
|
||||
.Count() >= count),
|
||||
FilterComparison.LessThan => queryable.Where(p =>
|
||||
p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() < count),
|
||||
FilterComparison.LessThanEqual => queryable.Where(p => p.ChapterPeople
|
||||
.Select(cp => cp.Chapter.Id)
|
||||
.Distinct()
|
||||
.Count() <= count),
|
||||
FilterComparison.NotEqual => queryable.Where(p =>
|
||||
p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() != count),
|
||||
FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches
|
||||
or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore
|
||||
or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast
|
||||
or FilterComparison.MustContains
|
||||
or FilterComparison.IsEmpty => throw new KavitaException(
|
||||
$"{comparison} not applicable for Person.ChapterCount"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,13 @@ using System.Linq.Expressions;
|
|||
using System.Threading.Tasks;
|
||||
using API.Data.Misc;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.KavitaPlus.Manage;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Person;
|
||||
using API.Entities.Scrobble;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
|
|
@ -273,6 +276,27 @@ public static class QueryableExtensions
|
|||
};
|
||||
}
|
||||
|
||||
public static IQueryable<Person> SortBy(this IQueryable<Person> query, PersonSortOptions? sort)
|
||||
{
|
||||
if (sort == null)
|
||||
{
|
||||
return query.OrderBy(p => p.Name);
|
||||
}
|
||||
|
||||
return sort.SortField switch
|
||||
{
|
||||
PersonSortField.Name when sort.IsAscending => query.OrderBy(p => p.Name),
|
||||
PersonSortField.Name => query.OrderByDescending(p => p.Name),
|
||||
PersonSortField.SeriesCount when sort.IsAscending => query.OrderBy(p => p.SeriesMetadataPeople.Count),
|
||||
PersonSortField.SeriesCount => query.OrderByDescending(p => p.SeriesMetadataPeople.Count),
|
||||
PersonSortField.ChapterCount when sort.IsAscending => query.OrderBy(p => p.ChapterPeople.Count),
|
||||
PersonSortField.ChapterCount => query.OrderByDescending(p => p.ChapterPeople.Count),
|
||||
_ => query.OrderBy(p => p.Name)
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs either OrderBy or OrderByDescending on the given query based on the value of SortOptions.IsAscending.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Linq;
|
|||
using API.Data.Misc;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Entities.Person;
|
||||
|
||||
namespace API.Extensions.QueryExtensions;
|
||||
|
|
@ -26,6 +27,7 @@ public static class RestrictByAgeExtensions
|
|||
return q;
|
||||
}
|
||||
|
||||
|
||||
public static IQueryable<Chapter> RestrictAgainstAgeRestriction(this IQueryable<Chapter> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
|
@ -39,20 +41,6 @@ public static class RestrictByAgeExtensions
|
|||
return q;
|
||||
}
|
||||
|
||||
[Obsolete]
|
||||
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
}
|
||||
|
||||
public static IQueryable<AppUserCollection> RestrictAgainstAgeRestriction(this IQueryable<AppUserCollection> queryable, AgeRestriction restriction)
|
||||
{
|
||||
|
|
@ -74,12 +62,15 @@ public static class RestrictByAgeExtensions
|
|||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
return queryable.Where(c =>
|
||||
c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) ||
|
||||
c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
return queryable.Where(c =>
|
||||
c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) ||
|
||||
c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown)
|
||||
);
|
||||
}
|
||||
|
||||
public static IQueryable<Tag> RestrictAgainstAgeRestriction(this IQueryable<Tag> queryable, AgeRestriction restriction)
|
||||
|
|
@ -88,12 +79,15 @@ public static class RestrictByAgeExtensions
|
|||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
return queryable.Where(c =>
|
||||
c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) ||
|
||||
c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
return queryable.Where(c =>
|
||||
c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) ||
|
||||
c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown)
|
||||
);
|
||||
}
|
||||
|
||||
public static IQueryable<Person> RestrictAgainstAgeRestriction(this IQueryable<Person> queryable, AgeRestriction restriction)
|
||||
|
|
|
|||
|
|
@ -286,7 +286,8 @@ public class AutoMapperProfiles : Profile
|
|||
CreateMap<AppUserBookmark, BookmarkDto>();
|
||||
|
||||
CreateMap<ReadingList, ReadingListDto>()
|
||||
.ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count));
|
||||
.ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count))
|
||||
.ForMember(dest => dest.OwnerUserName, opt => opt.MapFrom(src => src.AppUser.UserName));
|
||||
CreateMap<ReadingListItem, ReadingListItemDto>();
|
||||
CreateMap<ScrobbleError, ScrobbleErrorDto>();
|
||||
CreateMap<ChapterDto, TachiyomiChapterDto>();
|
||||
|
|
|
|||
31
API/Helpers/Converters/PersonFilterFieldValueConverter.cs
Normal file
31
API/Helpers/Converters/PersonFilterFieldValueConverter.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Helpers.Converters;
|
||||
|
||||
public static class PersonFilterFieldValueConverter
|
||||
{
|
||||
public static object ConvertValue(PersonFilterField field, string value)
|
||||
{
|
||||
return field switch
|
||||
{
|
||||
PersonFilterField.Name => value,
|
||||
PersonFilterField.Role => ParsePersonRoles(value),
|
||||
PersonFilterField.SeriesCount => int.Parse(value),
|
||||
PersonFilterField.ChapterCount => int.Parse(value),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported")
|
||||
};
|
||||
}
|
||||
|
||||
private static IList<PersonRole> ParsePersonRoles(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return [];
|
||||
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(v => Enum.Parse<PersonRole>(v.Trim()))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
|
@ -10,11 +10,9 @@ using API.Entities.Interfaces;
|
|||
using API.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetVips;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using SixLabors.ImageSharp.Processing.Processors.Quantization;
|
||||
using Color = System.Drawing.Color;
|
||||
using Image = NetVips.Image;
|
||||
|
||||
namespace API.Services;
|
||||
|
|
@ -750,7 +748,7 @@ public class ImageService : IImageService
|
|||
}
|
||||
|
||||
|
||||
public static Color HexToRgb(string? hex)
|
||||
public static (int R, int G, int B) HexToRgb(string? hex)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null");
|
||||
|
||||
|
|
@ -774,7 +772,7 @@ public class ImageService : IImageService
|
|||
var g = Convert.ToInt32(hex.Substring(2, 2), 16);
|
||||
var b = Convert.ToInt32(hex.Substring(4, 2), 16);
|
||||
|
||||
return Color.FromArgb(r, g, b);
|
||||
return (r, g, b);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,9 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
/// <summary>
|
||||
/// Returns the match results for a Series from UI Flow
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Will extract alternative names like Localized name, year will send as ReleaseYear but fallback to Comic Vine syntax if applicable
|
||||
/// </remarks>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto)
|
||||
|
|
@ -212,19 +215,26 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
var potentialAnilistId = ScrobblingService.ExtractId<int?>(dto.Query, ScrobblingService.AniListWeblinkWebsite);
|
||||
var potentialMalId = ScrobblingService.ExtractId<long?>(dto.Query, ScrobblingService.MalWeblinkWebsite);
|
||||
|
||||
List<string> altNames = [series.LocalizedName, series.OriginalName];
|
||||
if (potentialAnilistId == null && potentialMalId == null && !string.IsNullOrEmpty(dto.Query))
|
||||
var format = series.Library.Type.ConvertToPlusMediaFormat(series.Format);
|
||||
var otherNames = ExtractAlternativeNames(series);
|
||||
|
||||
var year = series.Metadata.ReleaseYear;
|
||||
if (year == 0 && format == PlusMediaFormat.Comic && !string.IsNullOrWhiteSpace(series.Name))
|
||||
{
|
||||
altNames.Add(dto.Query);
|
||||
var potentialYear = Parser.ParseYear(series.Name);
|
||||
if (!string.IsNullOrEmpty(potentialYear))
|
||||
{
|
||||
year = int.Parse(potentialYear);
|
||||
}
|
||||
}
|
||||
|
||||
var matchRequest = new MatchSeriesRequestDto()
|
||||
{
|
||||
Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
|
||||
Format = format,
|
||||
Query = dto.Query,
|
||||
SeriesName = series.Name,
|
||||
AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(),
|
||||
Year = series.Metadata.ReleaseYear,
|
||||
AlternativeNames = otherNames,
|
||||
Year = year,
|
||||
AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series),
|
||||
MalId = potentialMalId ?? ScrobblingService.GetMalId(series)
|
||||
};
|
||||
|
|
@ -254,6 +264,12 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
return ArraySegment<ExternalSeriesMatchDto>.Empty;
|
||||
}
|
||||
|
||||
private static List<string> ExtractAlternativeNames(Series series)
|
||||
{
|
||||
List<string> altNames = [series.LocalizedName, series.OriginalName];
|
||||
return altNames.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves Metadata about a Recommended External Series
|
||||
|
|
|
|||
|
|
@ -130,22 +130,23 @@ public class LicenseService(
|
|||
if (cacheValue.HasValue) return cacheValue.Value;
|
||||
}
|
||||
|
||||
var result = false;
|
||||
try
|
||||
{
|
||||
var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
var result = await IsLicenseValid(serverSetting.Value);
|
||||
await provider.FlushAsync();
|
||||
await provider.SetAsync(CacheKey, result, _licenseCacheTimeout);
|
||||
return result;
|
||||
result = await IsLicenseValid(serverSetting.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "There was an issue connecting to Kavita+");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await provider.FlushAsync();
|
||||
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
||||
await provider.SetAsync(CacheKey, result, _licenseCacheTimeout);
|
||||
}
|
||||
|
||||
return false;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -432,6 +432,7 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService
|
|||
existingProfile.SwipeToPaginate = dto.SwipeToPaginate;
|
||||
existingProfile.AllowAutomaticWebtoonReaderDetection = dto.AllowAutomaticWebtoonReaderDetection;
|
||||
existingProfile.WidthOverride = dto.WidthOverride;
|
||||
existingProfile.DisableWidthOverride = dto.DisableWidthOverride;
|
||||
|
||||
// Book Reader
|
||||
existingProfile.BookReaderMargin = dto.BookReaderMargin;
|
||||
|
|
|
|||
|
|
@ -206,17 +206,12 @@ public class CoverDbService : ICoverDbService
|
|||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
_logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize());
|
||||
// Download the publisher file using Flurl
|
||||
var publisherStream = await publisherLink
|
||||
.AllowHttpStatus("2xx,304")
|
||||
.GetStreamAsync();
|
||||
|
||||
// Create the destination file path
|
||||
using var image = Image.NewFromStream(publisherStream);
|
||||
var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat);
|
||||
|
||||
image.WriteToFile(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
_logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize());
|
||||
await DownloadImageFromUrl(publisherName, encodeFormat, publisherLink, _directoryService.PublisherDirectory);
|
||||
|
||||
_logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize());
|
||||
|
||||
return filename;
|
||||
|
|
@ -302,7 +297,27 @@ public class CoverDbService : ICoverDbService
|
|||
.GetStreamAsync();
|
||||
|
||||
using var image = Image.NewFromStream(imageStream);
|
||||
image.WriteToFile(targetFile);
|
||||
try
|
||||
{
|
||||
image.WriteToFile(targetFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
|
@ -385,14 +400,13 @@ public class CoverDbService : ICoverDbService
|
|||
private async Task<string> FallbackToKavitaReaderPublisher(string publisherName)
|
||||
{
|
||||
const string publisherFileName = "publishers.txt";
|
||||
var externalLink = string.Empty;
|
||||
var allOverrides = await GetCachedData(publisherFileName) ??
|
||||
await $"{NewHost}publishers/{publisherFileName}".GetStringAsync();
|
||||
|
||||
// Cache immediately
|
||||
await CacheDataAsync(publisherFileName, allOverrides);
|
||||
|
||||
if (string.IsNullOrEmpty(allOverrides)) return externalLink;
|
||||
if (string.IsNullOrEmpty(allOverrides)) return string.Empty;
|
||||
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
|
|
@ -415,7 +429,7 @@ public class CoverDbService : ICoverDbService
|
|||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
return $"{NewHost}publishers/{externalLink}";
|
||||
return $"{NewHost}publishers/{externalFile}";
|
||||
}
|
||||
|
||||
private async Task CacheDataAsync(string fileName, string? content)
|
||||
|
|
@ -572,8 +586,7 @@ public class CoverDbService : ICoverDbService
|
|||
var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase);
|
||||
if (choseNewImage)
|
||||
{
|
||||
|
||||
// Don't delete series cover, unless it's an override, otherwise the first chapter cover will be null
|
||||
// Don't delete the Series cover unless it is an override, otherwise the first chapter will be null
|
||||
if (existingPath.Contains(ImageService.GetSeriesFormat(series.Id)))
|
||||
{
|
||||
_directoryService.DeleteFiles([existingPath]);
|
||||
|
|
@ -624,6 +637,7 @@ public class CoverDbService : ICoverDbService
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: Refactor this to IHasCoverImage instead of a hard entity type
|
||||
public async Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
|
|
|
|||
|
|
@ -1159,6 +1159,12 @@ public static partial class Parser
|
|||
return !string.IsNullOrEmpty(name) && SeriesAndYearRegex.IsMatch(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a Year from a Comic Series: Series Name (YEAR)
|
||||
/// </summary>
|
||||
/// <example>Harley Quinn (2024) returns 2024</example>
|
||||
/// <param name="name"></param>
|
||||
/// <returns></returns>
|
||||
public static string ParseYear(string? name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return string.Empty;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue