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:
Joe Milazzo 2025-06-14 12:14:04 -05:00 committed by GitHub
parent 00c4712fc3
commit c52ed1f65d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
147 changed files with 6612 additions and 958 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -0,0 +1,8 @@
namespace API.DTOs.Filtering;
public enum PersonSortField
{
Name = 1,
SeriesCount = 2,
ChapterCount = 3
}

View file

@ -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;
}

View file

@ -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,
}

View file

@ -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; }
}

View file

@ -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; }

View file

@ -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;
}

View 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; }
}

View file

@ -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; }
}

View 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; }
}

View 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;
}

View file

@ -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; }

View file

@ -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; }

View file

@ -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;

View file

@ -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

View file

@ -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");
}
}
}

View file

@ -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")

View file

@ -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)

View file

@ -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);
}
}

View file

@ -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)

View file

@ -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}")
};
}

View file

@ -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();

View file

@ -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

View file

@ -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

View file

@ -10,5 +10,5 @@ public enum SideNavStreamType
ExternalSource = 6,
AllSeries = 7,
WantToRead = 8,
BrowseAuthors = 9
BrowsePeople = 9
}

View file

@ -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;
}
}

View 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")
};
}
}

View file

@ -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>

View file

@ -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)

View file

@ -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>();

View 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();
}
}

View file

@ -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);
}

View file

@ -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

View file

@ -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>

View file

@ -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;

View file

@ -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))

View file

@ -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;