using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using AutoMapper; using Kavita.API.Database; using Kavita.API.Repositories; using Kavita.API.Services; using Kavita.API.Services.Metadata; using Kavita.API.Services.SignalR; using Kavita.Common.Extensions; using Kavita.Common.Helpers; using Kavita.Models.Constants; using Kavita.Models.DTOs; using Kavita.Models.DTOs.Metadata.Browse; using Kavita.Models.DTOs.Metadata.Browse.Requests; using Kavita.Models.DTOs.Person; using Kavita.Models.DTOs.Recommendation; using Kavita.Models.DTOs.SignalR; using Kavita.Models.Entities.Enums; using Kavita.Server.Attributes; using Kavita.Server.Extensions; using Kavita.Services.Plus; using Kavita.Services.Scanner; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Kavita.Server.Controllers; public class PersonController( IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper, ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService) : BaseApiController { [HttpGet] public async Task> GetPersonByName(string name) { var person = await unitOfWork.PersonRepository.GetPersonDtoByName(name, UserId); if (person == null) return NotFound(); person.Roles = (await unitOfWork.PersonRepository.GetRolesForPersonByName(person.Id, UserId)).ToList(); EnrichWithWebLinks(person); return Ok(person); } /// /// Populate from set ids /// /// /// must be set for this to work private static void EnrichWithWebLinks(PersonDto personDto) { if (personDto.Roles == null) return; var isCharacter = personDto.Roles.Count == 1 && personDto.Roles.Contains(PersonRole.Character); personDto.WebLinks = []; if (personDto.AniListId != 0) { var urlPrefix = isCharacter ? ScrobblingService.AniListCharacterWebsite : ScrobblingService.AniListStaffWebsite; personDto.WebLinks.Add($"{urlPrefix}{personDto.AniListId}"); } if (personDto.MalId != 0) { var urlPrefix = isCharacter ? ScrobblingService.MalCharacterWebsite : ScrobblingService.MalStaffWebsite; personDto.WebLinks.Add($"{urlPrefix}{personDto.MalId}"); } // Hardcover currently does not seem to have characters if (!string.IsNullOrEmpty(personDto.HardcoverId) && !isCharacter) { personDto.WebLinks.Add($"{ScrobblingService.HardcoverStaffWebsite}{personDto.HardcoverId}"); } } /// /// Find a person by name or alias against a query string /// /// /// [HttpGet("search")] public async Task>> SearchPeople([FromQuery] string queryString) { return Ok(await unitOfWork.PersonRepository.SearchPeople(queryString)); } /// /// Returns all roles for a Person /// /// /// [HttpGet("roles")] public async Task>> GetRolesForPersonByName(int personId) { return Ok(await unitOfWork.PersonRepository.GetRolesForPersonByName(personId, UserId)); } /// /// Returns a list of authors and artists for browsing /// /// /// /// [HttpPost("all")] public async Task>> GetPeopleForBrowse(BrowsePersonFilterDto filter, [FromQuery] UserParams? userParams) { userParams ??= UserParams.Default; var list = await unitOfWork.PersonRepository.GetBrowsePersonDtos(UserId, filter, userParams); Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); return Ok(list); } /// /// Updates the Person /// /// /// [HttpPost("update")] [Authorize(PolicyGroups.AdminPolicy)] public async Task> UpdatePerson(UpdatePersonDto dto) { // This needs to get all people and update them equally var person = await unitOfWork.PersonRepository.GetPersonById(dto.Id, PersonIncludes.Aliases); if (person == null) return BadRequest(localizationService.Translate(UserId, "person-doesnt-exist")); if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await localizationService.Translate(UserId, "person-name-required")); // Validate the name is unique if (dto.Name != person.Name && !(await unitOfWork.PersonRepository.IsNameUnique(dto.Name))) { return BadRequest(await localizationService.Translate(UserId, "person-name-unique")); } // Update name first, in case it got moved to aliases person.Name = dto.Name.Trim(); person.NormalizedName = person.Name.ToNormalized(); var success = await personService.UpdatePersonAliasesAsync(person, dto.Aliases); if (!success) return BadRequest(await localizationService.Translate(UserId, "aliases-have-overlap")); person.Description = dto.Description ?? string.Empty; person.CoverImageLocked = dto.CoverImageLocked; if (dto.MalId is > 0) { person.MalId = (long) dto.MalId; } if (dto.AniListId is > 0) { person.AniListId = (int) dto.AniListId; } if (!string.IsNullOrEmpty(dto.HardcoverId?.Trim())) { person.HardcoverId = dto.HardcoverId.Trim(); } var asin = dto.Asin?.Trim(); if (!string.IsNullOrEmpty(asin) && Parser.IsLikelyValidAsin(asin)) { person.Asin = asin; } unitOfWork.PersonRepository.Update(person); await unitOfWork.CommitAsync(); return Ok(mapper.Map(person)); } /// /// Attempts to download the cover from CoversDB (Note: Not yet release in Kavita) /// /// /// [PersonAccess] [HttpPost("fetch-cover")] public async Task> DownloadCoverImage([FromQuery] int personId) { var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var person = await unitOfWork.PersonRepository.GetPersonById(personId); if (person == null) return BadRequest(localizationService.Translate(UserId, "person-doesnt-exist")); var personImage = await coverDbService.DownloadPersonImageAsync(person, settings.EncodeMediaAs); if (string.IsNullOrEmpty(personImage)) { return BadRequest(await localizationService.Translate(UserId, "person-image-doesnt-exist")); } person.CoverImage = personImage; imageService.UpdateColorScape(person); unitOfWork.PersonRepository.Update(person); await unitOfWork.CommitAsync(); await eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(person.Id, "person"), false); return Ok(personImage); } /// /// Returns the top 20 series that the "person" is known for. This will use Average Rating when applicable (Kavita+ field), else it's a random sort /// /// /// [PersonAccess] [HttpGet("series-known-for")] public async Task>> GetKnownSeries(int personId) { return Ok(await unitOfWork.PersonRepository.GetSeriesKnownFor(personId, UserId)); } /// /// Returns all individual chapters by role. Limited to 20 results. /// /// /// /// [PersonAccess] [HttpGet("chapters-by-role")] public async Task>> GetChaptersByRole(int personId, PersonRole role) { return Ok(await unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, UserId, role)); } /// /// Merges Persons into one, this action is irreversible /// /// /// [HttpPost("merge")] [Authorize(PolicyGroups.AdminPolicy)] public async Task> 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(dst)); } /// /// 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. /// /// alias check request /// [HttpPost("valid-alias")] public async Task> IsValidAlias(PersonAliasCheckDto dto) { var person = await unitOfWork.PersonRepository.GetPersonById(dto.PersonId, PersonIncludes.Aliases); if (person == null) return NotFound(); var aliasIsName = dto.Name.ToNormalized() == dto.Alias.ToNormalized(); var existingAlias = await unitOfWork.PersonRepository.AnyAliasExist(dto.Alias); return Ok(!existingAlias && !aliasIsName); } }