Merged develop in

This commit is contained in:
Joseph Milazzo 2025-06-20 12:35:57 -05:00
commit 2af01b654d
256 changed files with 17954 additions and 3884 deletions

View file

@ -0,0 +1,112 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IAppUserReadingProfileRepository
{
/// <summary>
/// Return the given profile if it belongs the user
/// </summary>
/// <param name="userId"></param>
/// <param name="profileId"></param>
/// <returns></returns>
Task<AppUserReadingProfile?> GetUserProfile(int userId, int profileId);
/// <summary>
/// Returns all reading profiles for the user
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
Task<IList<AppUserReadingProfile>> GetProfilesForUser(int userId, bool skipImplicit = false);
/// <summary>
/// Returns all reading profiles for the user
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
Task<IList<UserReadingProfileDto>> GetProfilesDtoForUser(int userId, bool skipImplicit = false);
/// <summary>
/// Is there a user reading profile with this name (normalized)
/// </summary>
/// <param name="userId"></param>
/// <param name="name"></param>
/// <returns></returns>
Task<bool> IsProfileNameInUse(int userId, string name);
void Add(AppUserReadingProfile readingProfile);
void Update(AppUserReadingProfile readingProfile);
void Remove(AppUserReadingProfile readingProfile);
void RemoveRange(IEnumerable<AppUserReadingProfile> readingProfiles);
}
public class AppUserReadingProfileRepository(DataContext context, IMapper mapper): IAppUserReadingProfileRepository
{
public async Task<AppUserReadingProfile?> GetUserProfile(int userId, int profileId)
{
return await context.AppUserReadingProfiles
.Where(rp => rp.AppUserId == userId && rp.Id == profileId)
.FirstOrDefaultAsync();
}
public async Task<IList<AppUserReadingProfile>> GetProfilesForUser(int userId, bool skipImplicit = false)
{
return await context.AppUserReadingProfiles
.Where(rp => rp.AppUserId == userId)
.WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit)
.ToListAsync();
}
/// <summary>
/// Returns all Reading Profiles for the User
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<IList<UserReadingProfileDto>> GetProfilesDtoForUser(int userId, bool skipImplicit = false)
{
return await context.AppUserReadingProfiles
.Where(rp => rp.AppUserId == userId)
.WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit)
.ProjectTo<UserReadingProfileDto>(mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<bool> IsProfileNameInUse(int userId, string name)
{
var normalizedName = name.ToNormalized();
return await context.AppUserReadingProfiles
.Where(rp => rp.NormalizedName == normalizedName && rp.AppUserId == userId)
.AnyAsync();
}
public void Add(AppUserReadingProfile readingProfile)
{
context.AppUserReadingProfiles.Add(readingProfile);
}
public void Update(AppUserReadingProfile readingProfile)
{
context.AppUserReadingProfiles.Update(readingProfile).State = EntityState.Modified;
}
public void Remove(AppUserReadingProfile readingProfile)
{
context.AppUserReadingProfiles.Remove(readingProfile);
}
public void RemoveRange(IEnumerable<AppUserReadingProfile> readingProfiles)
{
context.AppUserReadingProfiles.RemoveRange(readingProfiles);
}
}

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
@ -111,7 +114,7 @@ public class GenreRepository : IGenreRepository
/// <summary>
/// Returns a set of Genre tags for a set of library Ids.
/// UserId will restrict returned Genres based on user's age restriction and library access.
/// AppUserId will restrict returned Genres based on user's age restriction and library access.
/// </summary>
/// <param name="userId"></param>
/// <param name="libraryIds"></param>
@ -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

@ -757,7 +757,7 @@ public class UserRepository : IUserRepository
/// <summary>
/// Fetches the UserId by API Key. This does not include any extra information
/// Fetches the AppUserId by API Key. This does not include any extra information
/// </summary>
/// <param name="apiKey"></param>
/// <returns></returns>