People Aliases and Merging (#3795)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
parent
cd2a6af6f2
commit
7ce36bfc44
67 changed files with 5288 additions and 284 deletions
|
@ -49,6 +49,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||
public DbSet<ReadingList> ReadingList { get; set; } = null!;
|
||||
public DbSet<ReadingListItem> ReadingListItem { get; set; } = null!;
|
||||
public DbSet<Person> Person { get; set; } = null!;
|
||||
public DbSet<PersonAlias> PersonAlias { get; set; } = null!;
|
||||
public DbSet<Genre> Genre { get; set; } = null!;
|
||||
public DbSet<Tag> Tag { get; set; } = null!;
|
||||
public DbSet<SiteTheme> SiteTheme { get; set; } = null!;
|
||||
|
|
3571
API/Data/Migrations/20250507221026_PersonAliases.Designer.cs
generated
Normal file
3571
API/Data/Migrations/20250507221026_PersonAliases.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
47
API/Data/Migrations/20250507221026_PersonAliases.cs
Normal file
47
API/Data/Migrations/20250507221026_PersonAliases.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PersonAliases : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PersonAlias",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Alias = table.Column<string>(type: "TEXT", nullable: true),
|
||||
NormalizedAlias = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PersonId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PersonAlias", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PersonAlias_Person_PersonId",
|
||||
column: x => x.PersonId,
|
||||
principalTable: "Person",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PersonAlias_PersonId",
|
||||
table: "PersonAlias",
|
||||
column: "PersonId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PersonAlias");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1836,6 +1836,28 @@ namespace API.Data.Migrations
|
|||
b.ToTable("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person.PersonAlias", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Alias")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedAlias")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("PersonAlias");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
|
||||
{
|
||||
b.Property<int>("SeriesMetadataId")
|
||||
|
@ -3082,6 +3104,17 @@ namespace API.Data.Migrations
|
|||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person.PersonAlias", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.Person.Person", "Person")
|
||||
.WithMany("Aliases")
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.Person.Person", "Person")
|
||||
|
@ -3496,6 +3529,8 @@ namespace API.Data.Migrations
|
|||
|
||||
modelBuilder.Entity("API.Entities.Person.Person", b =>
|
||||
{
|
||||
b.Navigation("Aliases");
|
||||
|
||||
b.Navigation("ChapterPeople");
|
||||
|
||||
b.Navigation("SeriesMetadataPeople");
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
|
@ -14,6 +16,17 @@ using Microsoft.EntityFrameworkCore;
|
|||
namespace API.Data.Repositories;
|
||||
#nullable enable
|
||||
|
||||
[Flags]
|
||||
public enum PersonIncludes
|
||||
{
|
||||
None = 1 << 0,
|
||||
Aliases = 1 << 1,
|
||||
ChapterPeople = 1 << 2,
|
||||
SeriesPeople = 1 << 3,
|
||||
|
||||
All = Aliases | ChapterPeople | SeriesPeople,
|
||||
}
|
||||
|
||||
public interface IPersonRepository
|
||||
{
|
||||
void Attach(Person person);
|
||||
|
@ -23,24 +36,41 @@ public interface IPersonRepository
|
|||
void Remove(SeriesMetadataPeople person);
|
||||
void Update(Person person);
|
||||
|
||||
Task<IList<Person>> GetAllPeople();
|
||||
Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId);
|
||||
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role);
|
||||
Task<IList<Person>> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases);
|
||||
Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None);
|
||||
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None);
|
||||
Task RemoveAllPeopleNoLongerAssociated();
|
||||
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null);
|
||||
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null, PersonIncludes includes = PersonIncludes.None);
|
||||
|
||||
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<Person?> GetPersonById(int personId);
|
||||
Task<PersonDto?> GetPersonDtoByName(string name, int userId);
|
||||
Task<Person?> GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None);
|
||||
Task<PersonDto?> GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases);
|
||||
/// <summary>
|
||||
/// Returns a person matched on normalized name or alias
|
||||
/// </summary>
|
||||
/// <param name="name"></param>
|
||||
/// <param name="includes"></param>
|
||||
/// <returns></returns>
|
||||
Task<Person?> GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases);
|
||||
Task<bool> IsNameUnique(string name);
|
||||
|
||||
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
|
||||
Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role);
|
||||
Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames);
|
||||
Task<Person?> GetPersonByAniListId(int aniListId);
|
||||
/// <summary>
|
||||
/// Returns all people with a matching name, or alias
|
||||
/// </summary>
|
||||
/// <param name="normalizedNames"></param>
|
||||
/// <param name="includes"></param>
|
||||
/// <returns></returns>
|
||||
Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames, PersonIncludes includes = PersonIncludes.Aliases);
|
||||
Task<Person?> GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases);
|
||||
|
||||
Task<IList<PersonDto>> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases);
|
||||
|
||||
Task<bool> AnyAliasExist(string alias);
|
||||
}
|
||||
|
||||
public class PersonRepository : IPersonRepository
|
||||
|
@ -99,7 +129,7 @@ public class PersonRepository : IPersonRepository
|
|||
}
|
||||
|
||||
|
||||
public async Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null)
|
||||
public async Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null, PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||
|
@ -113,6 +143,7 @@ public class PersonRepository : IPersonRepository
|
|||
.Where(s => userLibs.Contains(s.LibraryId))
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.SelectMany(s => s.Metadata.People.Select(p => p.Person))
|
||||
.Includes(includes)
|
||||
.Distinct()
|
||||
.OrderBy(p => p.Name)
|
||||
.AsNoTracking()
|
||||
|
@ -193,27 +224,41 @@ public class PersonRepository : IPersonRepository
|
|||
return await PagedList<BrowsePersonDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<Person?> GetPersonById(int personId)
|
||||
public async Task<Person?> GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None)
|
||||
{
|
||||
return await _context.Person.Where(p => p.Id == personId)
|
||||
.Includes(includes)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<PersonDto?> GetPersonDtoByName(string name, int userId)
|
||||
public async Task<PersonDto?> GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
var normalized = name.ToNormalized();
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
return await _context.Person
|
||||
.Where(p => p.NormalizedName == normalized)
|
||||
.Includes(includes)
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public Task<Person?> GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
var normalized = name.ToNormalized();
|
||||
return _context.Person
|
||||
.Includes(includes)
|
||||
.Where(p => p.NormalizedName == normalized || p.Aliases.Any(pa => pa.NormalizedAlias == normalized))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> IsNameUnique(string name)
|
||||
{
|
||||
return !(await _context.Person.AnyAsync(p => p.Name == name));
|
||||
// Should this use Normalized to check?
|
||||
return !(await _context.Person
|
||||
.Includes(PersonIncludes.Aliases)
|
||||
.AnyAsync(p => p.Name == name || p.Aliases.Any(pa => pa.Alias == name)));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId)
|
||||
|
@ -245,45 +290,69 @@ public class PersonRepository : IPersonRepository
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames)
|
||||
public async Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames, PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
return await _context.Person
|
||||
.Where(p => normalizedNames.Contains(p.NormalizedName))
|
||||
.Includes(includes)
|
||||
.Where(p => normalizedNames.Contains(p.NormalizedName) || p.Aliases.Any(pa => normalizedNames.Contains(pa.NormalizedAlias)))
|
||||
.OrderBy(p => p.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Person?> GetPersonByAniListId(int aniListId)
|
||||
public async Task<Person?> GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
return await _context.Person
|
||||
.Where(p => p.AniListId == aniListId)
|
||||
.Includes(includes)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<Person>> GetAllPeople()
|
||||
public async Task<IList<PersonDto>> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
searchQuery = searchQuery.ToNormalized();
|
||||
|
||||
return await _context.Person
|
||||
.Includes(includes)
|
||||
.Where(p => EF.Functions.Like(p.Name, $"%{searchQuery}%")
|
||||
|| p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")))
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> AnyAliasExist(string alias)
|
||||
{
|
||||
return await _context.PersonAlias.AnyAsync(pa => pa.NormalizedAlias == alias.ToNormalized());
|
||||
}
|
||||
|
||||
|
||||
public async Task<IList<Person>> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
return await _context.Person
|
||||
.Includes(includes)
|
||||
.OrderBy(p => p.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId)
|
||||
public async Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
return await _context.Person
|
||||
.Includes(includes)
|
||||
.OrderBy(p => p.Name)
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role)
|
||||
public async Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
return await _context.Person
|
||||
.Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters
|
||||
.Includes(includes)
|
||||
.OrderBy(p => p.Name)
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
|
|
|
@ -3,7 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Misc;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
|
|
@ -15,6 +15,7 @@ using API.DTOs.Filtering;
|
|||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.Scrobbling;
|
||||
|
@ -455,11 +456,18 @@ public class SeriesRepository : ISeriesRepository
|
|||
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.Persons = await _context.SeriesMetadata
|
||||
// I can't work out how to map people in DB layer
|
||||
var personIds = await _context.SeriesMetadata
|
||||
.SearchPeople(searchQuery, seriesIds)
|
||||
.Take(maxRecords)
|
||||
.OrderBy(t => t.NormalizedName)
|
||||
.Select(p => p.Id)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.Take(maxRecords)
|
||||
.ToListAsync();
|
||||
|
||||
result.Persons = await _context.Person
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.OrderBy(p => p.NormalizedName)
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
|
@ -475,8 +483,8 @@ public class SeriesRepository : ISeriesRepository
|
|||
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.Files = new List<MangaFileDto>();
|
||||
result.Chapters = new List<ChapterDto>();
|
||||
result.Files = [];
|
||||
result.Chapters = (List<ChapterDto>) [];
|
||||
|
||||
|
||||
if (includeChapterAndFiles)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue