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
|
@ -6,9 +6,9 @@ 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.Person;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities.Enums;
|
||||
|
@ -74,6 +74,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||
{
|
||||
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids));
|
||||
}
|
||||
|
||||
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId()));
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ using API.DTOs.CollectionTags;
|
|||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.OPDS;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.Search;
|
||||
using API.Entities;
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
|
@ -24,9 +27,10 @@ public class PersonController : BaseApiController
|
|||
private readonly ICoverDbService _coverDbService;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly IPersonService _personService;
|
||||
|
||||
public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper,
|
||||
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub)
|
||||
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_localizationService = localizationService;
|
||||
|
@ -34,6 +38,7 @@ public class PersonController : BaseApiController
|
|||
_coverDbService = coverDbService;
|
||||
_imageService = imageService;
|
||||
_eventHub = eventHub;
|
||||
_personService = personService;
|
||||
}
|
||||
|
||||
|
||||
|
@ -43,6 +48,17 @@ public class PersonController : BaseApiController
|
|||
return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find a person by name or alias against a query string
|
||||
/// </summary>
|
||||
/// <param name="queryString"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<List<PersonDto>>> SearchPeople([FromQuery] string queryString)
|
||||
{
|
||||
return Ok(await _unitOfWork.PersonRepository.SearchPeople(queryString));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all roles for a Person
|
||||
/// </summary>
|
||||
|
@ -54,6 +70,7 @@ public class PersonController : BaseApiController
|
|||
return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId()));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of authors and artists for browsing
|
||||
/// </summary>
|
||||
|
@ -78,7 +95,7 @@ public class PersonController : BaseApiController
|
|||
public async Task<ActionResult<PersonDto>> UpdatePerson(UpdatePersonDto dto)
|
||||
{
|
||||
// This needs to get all people and update them equally
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id);
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id, PersonIncludes.Aliases);
|
||||
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
|
||||
|
||||
if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required"));
|
||||
|
@ -90,6 +107,10 @@ public class PersonController : BaseApiController
|
|||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique"));
|
||||
}
|
||||
|
||||
var success = await _personService.UpdatePersonAliasesAsync(person, dto.Aliases);
|
||||
if (!success) return BadRequest(await _localizationService.Translate(User.GetUserId(), "aliases-have-overlap"));
|
||||
|
||||
|
||||
person.Name = dto.Name?.Trim();
|
||||
person.Description = dto.Description ?? string.Empty;
|
||||
person.CoverImageLocked = dto.CoverImageLocked;
|
||||
|
@ -173,5 +194,41 @@ public class PersonController : BaseApiController
|
|||
return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges Persons into one, this action is irreversible
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("merge")]
|
||||
public async Task<ActionResult<PersonDto>> MergePeople(PersonMergeDto dto)
|
||||
{
|
||||
var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All);
|
||||
if (dst == null) return BadRequest();
|
||||
|
||||
var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId, PersonIncludes.All);
|
||||
if (src == null) return BadRequest();
|
||||
|
||||
await _personService.MergePeopleAsync(src, dst);
|
||||
await _eventHub.SendMessageAsync(MessageFactory.PersonMerged, MessageFactory.PersonMergedMessage(dst, src));
|
||||
|
||||
return Ok(_mapper.Map<PersonDto>(dst));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias.
|
||||
/// </summary>
|
||||
/// <param name="personId"></param>
|
||||
/// <param name="alias"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("valid-alias")]
|
||||
public async Task<ActionResult<bool>> IsValidAlias(int personId, string alias)
|
||||
{
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonById(personId, PersonIncludes.Aliases);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
var existingAlias = await _unitOfWork.PersonRepository.AnyAliasExist(alias);
|
||||
return Ok(!existingAlias && person.NormalizedName != alias.ToNormalized());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ using System.Threading.Tasks;
|
|||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
|
|
|
@ -63,6 +63,7 @@ public class SearchController : BaseApiController
|
|||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
|
||||
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Metadata;
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
namespace API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Used to browse writers and click in to see their series
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
using System.Runtime.Serialization;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.Person;
|
||||
#nullable enable
|
||||
|
||||
public class PersonDto
|
||||
|
@ -13,6 +13,7 @@ public class PersonDto
|
|||
public string? SecondaryColor { get; set; }
|
||||
|
||||
public string? CoverImage { get; set; }
|
||||
public List<string> Aliases { get; set; } = [];
|
||||
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
|
|
17
API/DTOs/Person/PersonMergeDto.cs
Normal file
17
API/DTOs/Person/PersonMergeDto.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
public sealed record PersonMergeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The id of the person being merged into
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int DestId { get; init; }
|
||||
/// <summary>
|
||||
/// The id of the person being merged. This person will be removed, and become an alias of <see cref="DestId"/>
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int SrcId { get; init; }
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace API.DTOs;
|
||||
#nullable enable
|
||||
|
@ -11,6 +12,7 @@ public sealed record UpdatePersonDto
|
|||
public bool CoverImageLocked { get; set; }
|
||||
[Required]
|
||||
public string Name {get; set;}
|
||||
public IList<string> Aliases { get; set; } = [];
|
||||
public string? Description { get; set; }
|
||||
|
||||
public int? AniListId { get; set; }
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using API.DTOs.Person;
|
||||
|
||||
namespace API.DTOs.ReadingLists;
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
using API.DTOs.Collection;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.ReadingLists;
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -8,8 +8,7 @@ public class Person : IHasCoverImage
|
|||
public int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string NormalizedName { get; set; }
|
||||
|
||||
//public ICollection<PersonAlias> Aliases { get; set; } = default!;
|
||||
public ICollection<PersonAlias> Aliases { get; set; } = [];
|
||||
|
||||
public string? CoverImage { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
|
@ -47,8 +46,8 @@ public class Person : IHasCoverImage
|
|||
//public long MetronId { get; set; } = 0;
|
||||
|
||||
// Relationships
|
||||
public ICollection<ChapterPeople> ChapterPeople { get; set; } = new List<ChapterPeople>();
|
||||
public ICollection<SeriesMetadataPeople> SeriesMetadataPeople { get; set; } = new List<SeriesMetadataPeople>();
|
||||
public ICollection<ChapterPeople> ChapterPeople { get; set; } = [];
|
||||
public ICollection<SeriesMetadataPeople> SeriesMetadataPeople { get; set; } = [];
|
||||
|
||||
|
||||
public void ResetColorScape()
|
||||
|
|
11
API/Entities/Person/PersonAlias.cs
Normal file
11
API/Entities/Person/PersonAlias.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
namespace API.Entities.Person;
|
||||
|
||||
public class PersonAlias
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Alias { get; set; }
|
||||
public required string NormalizedAlias { get; set; }
|
||||
|
||||
public int PersonId { get; set; }
|
||||
public Person Person { get; set; }
|
||||
}
|
|
@ -53,6 +53,7 @@ public static class ApplicationServiceExtensions
|
|||
services.AddScoped<IMediaConversionService, MediaConversionService>();
|
||||
services.AddScoped<IStreamService, StreamService>();
|
||||
services.AddScoped<IRatingService, RatingService>();
|
||||
services.AddScoped<IPersonService, PersonService>();
|
||||
|
||||
services.AddScoped<IScannerService, ScannerService>();
|
||||
services.AddScoped<IProcessSeries, ProcessSeries>();
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Data.Misc;
|
||||
using API.Data.Repositories;
|
||||
|
@ -49,23 +50,26 @@ public static class SearchQueryableExtensions
|
|||
// Get people from SeriesMetadata
|
||||
var peopleFromSeriesMetadata = queryable
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.People)
|
||||
.Where(p => p.Person.Name != null && EF.Functions.Like(p.Person.Name, $"%{searchQuery}%"))
|
||||
.Select(p => p.Person);
|
||||
.SelectMany(sm => sm.People.Select(sp => sp.Person))
|
||||
.Where(p =>
|
||||
EF.Functions.Like(p.Name, $"%{searchQuery}%") ||
|
||||
p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%"))
|
||||
);
|
||||
|
||||
// Get people from ChapterPeople by navigating through Volume -> Series
|
||||
var peopleFromChapterPeople = queryable
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.Series.Volumes)
|
||||
.SelectMany(v => v.Chapters)
|
||||
.SelectMany(ch => ch.People)
|
||||
.Where(cp => cp.Person.Name != null && EF.Functions.Like(cp.Person.Name, $"%{searchQuery}%"))
|
||||
.Select(cp => cp.Person);
|
||||
.SelectMany(ch => ch.People.Select(cp => cp.Person))
|
||||
.Where(p =>
|
||||
EF.Functions.Like(p.Name, $"%{searchQuery}%") ||
|
||||
p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%"))
|
||||
);
|
||||
|
||||
// Combine both queries and ensure distinct results
|
||||
return peopleFromSeriesMetadata
|
||||
.Union(peopleFromChapterPeople)
|
||||
.Distinct()
|
||||
.Select(p => p)
|
||||
.OrderBy(p => p.NormalizedName);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
using System.Linq;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Metadata;
|
||||
using API.Entities.Person;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Extensions.QueryExtensions;
|
||||
|
@ -321,4 +321,25 @@ public static class IncludesExtensions
|
|||
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
public static IQueryable<Person> Includes(this IQueryable<Person> queryable, PersonIncludes includeFlags)
|
||||
{
|
||||
|
||||
if (includeFlags.HasFlag(PersonIncludes.Aliases))
|
||||
{
|
||||
queryable = queryable.Include(p => p.Aliases);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(PersonIncludes.ChapterPeople))
|
||||
{
|
||||
queryable = queryable.Include(p => p.ChapterPeople);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(PersonIncludes.SeriesPeople))
|
||||
{
|
||||
queryable = queryable.Include(p => p.SeriesMetadataPeople);
|
||||
}
|
||||
|
||||
return queryable;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ using API.DTOs.KavitaPlus.Manage;
|
|||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.MediaErrors;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.ReadingLists;
|
||||
|
@ -68,7 +69,8 @@ public class AutoMapperProfiles : Profile
|
|||
CreateMap<AppUserCollection, AppUserCollectionDto>()
|
||||
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName))
|
||||
.ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count));
|
||||
CreateMap<Person, PersonDto>();
|
||||
CreateMap<Person, PersonDto>()
|
||||
.ForMember(dest => dest.Aliases, opt => opt.MapFrom(src => src.Aliases.Select(s => s.Alias)));
|
||||
CreateMap<Genre, GenreTagDto>();
|
||||
CreateMap<Tag, TagDto>();
|
||||
CreateMap<AgeRating, AgeRatingDto>();
|
||||
|
|
19
API/Helpers/Builders/PersonAliasBuilder.cs
Normal file
19
API/Helpers/Builders/PersonAliasBuilder.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class PersonAliasBuilder : IEntityBuilder<PersonAlias>
|
||||
{
|
||||
private readonly PersonAlias _alias;
|
||||
public PersonAlias Build() => _alias;
|
||||
|
||||
public PersonAliasBuilder(string name)
|
||||
{
|
||||
_alias = new PersonAlias()
|
||||
{
|
||||
Alias = name.Trim(),
|
||||
NormalizedAlias = name.ToNormalized(),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using System.Linq;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
|
||||
|
@ -34,6 +32,20 @@ public class PersonBuilder : IEntityBuilder<Person>
|
|||
return this;
|
||||
}
|
||||
|
||||
public PersonBuilder WithAlias(string alias)
|
||||
{
|
||||
if (_person.Aliases.Any(a => a.NormalizedAlias.Equals(alias.ToNormalized())))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_person.Aliases.Add(new PersonAliasBuilder(alias).Build());
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople)
|
||||
{
|
||||
_person.SeriesMetadataPeople.Add(seriesMetadataPeople);
|
||||
|
|
|
@ -17,6 +17,20 @@ namespace API.Helpers;
|
|||
public static class PersonHelper
|
||||
{
|
||||
|
||||
public static Dictionary<string, Person> ConstructNameAndAliasDictionary(IList<Person> people)
|
||||
{
|
||||
var dict = new Dictionary<string, Person>();
|
||||
foreach (var person in people)
|
||||
{
|
||||
dict.TryAdd(person.NormalizedName, person);
|
||||
foreach (var alias in person.Aliases)
|
||||
{
|
||||
dict.TryAdd(alias.NormalizedAlias, person);
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection<SeriesMetadataPeople> metadataPeople,
|
||||
IEnumerable<ChapterPeople> chapterPeople, PersonRole role, IUnitOfWork unitOfWork)
|
||||
{
|
||||
|
@ -38,7 +52,9 @@ public static class PersonHelper
|
|||
|
||||
// Identify people to remove from metadataPeople
|
||||
var peopleToRemove = existingMetadataPeople
|
||||
.Where(person => !peopleToAddSet.Contains(person.Person.NormalizedName))
|
||||
.Where(person =>
|
||||
!peopleToAddSet.Contains(person.Person.NormalizedName) &&
|
||||
!person.Person.Aliases.Any(pa => peopleToAddSet.Contains(pa.NormalizedAlias)))
|
||||
.ToList();
|
||||
|
||||
// Remove identified people from metadataPeople
|
||||
|
@ -53,11 +69,7 @@ public static class PersonHelper
|
|||
.GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList());
|
||||
|
||||
// Prepare a dictionary for quick lookup of existing people by normalized name
|
||||
var existingPeopleDict = new Dictionary<string, Person>();
|
||||
foreach (var person in existingPeopleInDb)
|
||||
{
|
||||
existingPeopleDict.TryAdd(person.NormalizedName, person);
|
||||
}
|
||||
var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeopleInDb);
|
||||
|
||||
// Track the people to attach (newly created people)
|
||||
var peopleToAttach = new List<Person>();
|
||||
|
@ -129,15 +141,12 @@ public static class PersonHelper
|
|||
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople);
|
||||
|
||||
// Prepare a dictionary for quick lookup by normalized name
|
||||
var existingPeopleDict = new Dictionary<string, Person>();
|
||||
foreach (var person in existingPeople)
|
||||
{
|
||||
existingPeopleDict.TryAdd(person.NormalizedName, person);
|
||||
}
|
||||
var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeople);
|
||||
|
||||
// Identify people to remove (those present in ChapterPeople but not in the new list)
|
||||
foreach (var existingChapterPerson in existingChapterPeople
|
||||
.Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName)))
|
||||
var toRemove = existingChapterPeople
|
||||
.Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName));
|
||||
foreach (var existingChapterPerson in toRemove)
|
||||
{
|
||||
chapter.People.Remove(existingChapterPerson);
|
||||
unitOfWork.PersonRepository.Remove(existingChapterPerson);
|
||||
|
|
|
@ -212,6 +212,7 @@
|
|||
"user-no-access-library-from-series": "User does not have access to the library this series belongs to",
|
||||
"series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions",
|
||||
"kavitaplus-restricted": "This is restricted to Kavita+ only",
|
||||
"aliases-have-overlap": "One or more of the aliases have overlap with other people, cannot update",
|
||||
|
||||
"volume-num": "Volume {0}",
|
||||
"book-num": "Book {0}",
|
||||
|
|
147
API/Services/PersonService.cs
Normal file
147
API/Services/PersonService.cs
Normal file
|
@ -0,0 +1,147 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public interface IPersonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds src as an alias to dst, this is a destructive operation
|
||||
/// </summary>
|
||||
/// <param name="src">Merged person</param>
|
||||
/// <param name="dst">Remaining person</param>
|
||||
/// <remarks>The entities passed as arguments **must** include all relations</remarks>
|
||||
/// <returns></returns>
|
||||
Task MergePeopleAsync(Person src, Person dst);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the alias to the person, requires that the aliases are not shared with anyone else
|
||||
/// </summary>
|
||||
/// <remarks>This method does NOT commit changes</remarks>
|
||||
/// <param name="person"></param>
|
||||
/// <param name="aliases"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> UpdatePersonAliasesAsync(Person person, IList<string> aliases);
|
||||
}
|
||||
|
||||
public class PersonService(IUnitOfWork unitOfWork): IPersonService
|
||||
{
|
||||
|
||||
public async Task MergePeopleAsync(Person src, Person dst)
|
||||
{
|
||||
if (dst.Id == src.Id) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dst.Description) && !string.IsNullOrWhiteSpace(src.Description))
|
||||
{
|
||||
dst.Description = src.Description;
|
||||
}
|
||||
|
||||
if (dst.MalId == 0 && src.MalId != 0)
|
||||
{
|
||||
dst.MalId = src.MalId;
|
||||
}
|
||||
|
||||
if (dst.AniListId == 0 && src.AniListId != 0)
|
||||
{
|
||||
dst.AniListId = src.AniListId;
|
||||
}
|
||||
|
||||
if (dst.HardcoverId == null && src.HardcoverId != null)
|
||||
{
|
||||
dst.HardcoverId = src.HardcoverId;
|
||||
}
|
||||
|
||||
if (dst.Asin == null && src.Asin != null)
|
||||
{
|
||||
dst.Asin = src.Asin;
|
||||
}
|
||||
|
||||
if (dst.CoverImage == null && src.CoverImage != null)
|
||||
{
|
||||
dst.CoverImage = src.CoverImage;
|
||||
}
|
||||
|
||||
MergeChapterPeople(dst, src);
|
||||
MergeSeriesMetadataPeople(dst, src);
|
||||
|
||||
dst.Aliases.Add(new PersonAliasBuilder(src.Name).Build());
|
||||
|
||||
foreach (var alias in src.Aliases)
|
||||
{
|
||||
dst.Aliases.Add(alias);
|
||||
}
|
||||
|
||||
unitOfWork.PersonRepository.Remove(src);
|
||||
unitOfWork.PersonRepository.Update(dst);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
private static void MergeChapterPeople(Person dst, Person src)
|
||||
{
|
||||
|
||||
foreach (var chapter in src.ChapterPeople)
|
||||
{
|
||||
var alreadyPresent = dst.ChapterPeople
|
||||
.Any(x => x.ChapterId == chapter.ChapterId && x.Role == chapter.Role);
|
||||
|
||||
if (alreadyPresent) continue;
|
||||
|
||||
dst.ChapterPeople.Add(new ChapterPeople
|
||||
{
|
||||
Role = chapter.Role,
|
||||
ChapterId = chapter.ChapterId,
|
||||
Person = dst,
|
||||
KavitaPlusConnection = chapter.KavitaPlusConnection,
|
||||
OrderWeight = chapter.OrderWeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeSeriesMetadataPeople(Person dst, Person src)
|
||||
{
|
||||
foreach (var series in src.SeriesMetadataPeople)
|
||||
{
|
||||
var alreadyPresent = dst.SeriesMetadataPeople
|
||||
.Any(x => x.SeriesMetadataId == series.SeriesMetadataId && x.Role == series.Role);
|
||||
|
||||
if (alreadyPresent) continue;
|
||||
|
||||
dst.SeriesMetadataPeople.Add(new SeriesMetadataPeople
|
||||
{
|
||||
SeriesMetadataId = series.SeriesMetadataId,
|
||||
Role = series.Role,
|
||||
Person = dst,
|
||||
KavitaPlusConnection = series.KavitaPlusConnection,
|
||||
OrderWeight = series.OrderWeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdatePersonAliasesAsync(Person person, IList<string> aliases)
|
||||
{
|
||||
var normalizedAliases = aliases
|
||||
.Select(a => a.ToNormalized())
|
||||
.Where(a => !string.IsNullOrEmpty(a) && a != person.NormalizedName)
|
||||
.ToList();
|
||||
|
||||
if (normalizedAliases.Count == 0)
|
||||
{
|
||||
person.Aliases = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
var others = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedAliases);
|
||||
others = others.Where(p => p.Id != person.Id).ToList();
|
||||
|
||||
if (others.Count != 0) return false;
|
||||
|
||||
person.Aliases = aliases.Select(a => new PersonAliasBuilder(a).Build()).ToList();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ using API.DTOs.Collection;
|
|||
using API.DTOs.KavitaPlus.ExternalMetadata;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.Metadata.Matching;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.DTOs.SeriesDetail;
|
||||
|
@ -17,8 +18,10 @@ using API.Entities;
|
|||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Entities.MetadataMatching;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
|
@ -614,12 +617,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification;
|
||||
madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification;
|
||||
|
||||
var staff = (externalMetadata.Staff ?? []).Select(s =>
|
||||
{
|
||||
s.Name = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}";
|
||||
var staff = await SetNameAndAddAliases(settings, externalMetadata.Staff);
|
||||
|
||||
return s;
|
||||
}).ToList();
|
||||
madeModification = await UpdateWriters(series, settings, staff) || madeModification;
|
||||
madeModification = await UpdateArtists(series, settings, staff) || madeModification;
|
||||
madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification;
|
||||
|
@ -632,6 +631,49 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
return madeModification;
|
||||
}
|
||||
|
||||
private async Task<List<SeriesStaffDto>> SetNameAndAddAliases(MetadataSettingsDto settings, IList<SeriesStaffDto>? staff)
|
||||
{
|
||||
if (staff == null || staff.Count == 0) return [];
|
||||
|
||||
var nameMappings = staff.Select(s => new
|
||||
{
|
||||
Staff = s,
|
||||
PreferredName = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}",
|
||||
AlternativeName = !settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}"
|
||||
}).ToList();
|
||||
|
||||
var preferredNames = nameMappings.Select(n => n.PreferredName.ToNormalized()).Distinct().ToList();
|
||||
var alternativeNames = nameMappings.Select(n => n.AlternativeName.ToNormalized()).Distinct().ToList();
|
||||
|
||||
var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(preferredNames.Union(alternativeNames).ToList());
|
||||
var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople);
|
||||
|
||||
var modified = false;
|
||||
foreach (var mapping in nameMappings)
|
||||
{
|
||||
mapping.Staff.Name = mapping.PreferredName;
|
||||
|
||||
if (existingPeopleDictionary.ContainsKey(mapping.PreferredName.ToNormalized()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (existingPeopleDictionary.TryGetValue(mapping.AlternativeName.ToNormalized(), out var person))
|
||||
{
|
||||
modified = true;
|
||||
person.Aliases.Add(new PersonAliasBuilder(mapping.PreferredName).Build());
|
||||
}
|
||||
}
|
||||
|
||||
if (modified)
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
return [.. staff];
|
||||
}
|
||||
|
||||
private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings,
|
||||
ref List<string> processedTags, ref List<string> processedGenres)
|
||||
{
|
||||
|
|
|
@ -7,6 +7,7 @@ using API.Comparators;
|
|||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
@ -361,8 +362,7 @@ public class SeriesService : ISeriesService
|
|||
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
|
||||
|
||||
// Use a dictionary for quick lookups
|
||||
var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName)
|
||||
.ToDictionary(p => p.NormalizedName, p => p);
|
||||
var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople);
|
||||
|
||||
// List to track people that will be added to the metadata
|
||||
var peopleToAdd = new List<Person>();
|
||||
|
|
|
@ -579,7 +579,7 @@ public class CoverDbService : ICoverDbService
|
|||
else
|
||||
{
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
series.CoverImage = Path.GetFileName(existingPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using API.DTOs.Update;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
using API.Services.Plus;
|
||||
|
||||
|
@ -147,6 +148,10 @@ public static class MessageFactory
|
|||
/// Volume is removed from server
|
||||
/// </summary>
|
||||
public const string VolumeRemoved = "VolumeRemoved";
|
||||
/// <summary>
|
||||
/// A Person merged has been merged into another
|
||||
/// </summary>
|
||||
public const string PersonMerged = "PersonMerged";
|
||||
|
||||
public static SignalRMessage DashboardUpdateEvent(int userId)
|
||||
{
|
||||
|
@ -661,4 +666,17 @@ public static class MessageFactory
|
|||
EventType = ProgressEventType.Single,
|
||||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage PersonMergedMessage(Person dst, Person src)
|
||||
{
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = PersonMerged,
|
||||
Body = new
|
||||
{
|
||||
srcId = src.Id,
|
||||
dstName = dst.Name,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue