New Scanner + People Pages (#3286)

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-10-23 15:11:18 -07:00 committed by GitHub
parent 1ed0eae22d
commit ba20ad4ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 17529 additions and 3038 deletions

View file

@ -95,59 +95,73 @@ public class AutoMapperProfiles : Profile
opt =>
opt.MapFrom(
src => src.PagesRead));
CreateMap<SeriesMetadata, SeriesMetadataDto>()
.ForMember(dest => dest.Writers,
opt =>
opt.MapFrom(
src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.CoverArtists,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Characters,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Publishers,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Colorists,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Inkers,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Imprints,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Letterers,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Pencillers,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Translators,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Editors,
opt =>
opt.MapFrom(
src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Teams,
opt =>
opt.MapFrom(
src => src.People.Where(p => p.Role == PersonRole.Team).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Locations,
opt =>
opt.MapFrom(
src => src.People.Where(p => p.Role == PersonRole.Location).OrderBy(p => p.NormalizedName)))
// Map Writers
.ForMember(dest => dest.Writers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Writer)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map CoverArtists
.ForMember(dest => dest.CoverArtists, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.CoverArtist)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Publishers
.ForMember(dest => dest.Publishers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Publisher)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Characters
.ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Character)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Pencillers
.ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Penciller)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Inkers
.ForMember(dest => dest.Inkers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Inker)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Imprints
.ForMember(dest => dest.Imprints, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Imprint)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Colorists
.ForMember(dest => dest.Colorists, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Colorist)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Letterers
.ForMember(dest => dest.Letterers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Letterer)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Editors
.ForMember(dest => dest.Editors, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Editor)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Translators
.ForMember(dest => dest.Translators, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Translator)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Teams
.ForMember(dest => dest.Teams, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Team)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Locations
.ForMember(dest => dest.Locations, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Location)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Genres,
opt =>
opt.MapFrom(
@ -157,89 +171,73 @@ public class AutoMapperProfiles : Profile
opt.MapFrom(
src => src.Tags.OrderBy(p => p.NormalizedTitle)));
CreateMap<Chapter, ChapterMetadataDto>()
.ForMember(dest => dest.Writers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.CoverArtists,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Colorists,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Inkers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Imprints,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Letterers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Pencillers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Publishers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Translators,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Characters,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Editors,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Teams,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Team).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Locations,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Location).OrderBy(p => p.NormalizedName)))
;
CreateMap<Chapter, ChapterDto>()
.ForMember(dest => dest.Writers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.CoverArtists,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Colorists,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Inkers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Imprints,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Letterers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Pencillers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Publishers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Translators,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Characters,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Editors,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Teams,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Team).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Locations,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Location).OrderBy(p => p.NormalizedName)))
;
// Map Writers
.ForMember(dest => dest.Writers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Writer)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map CoverArtists
.ForMember(dest => dest.CoverArtists, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.CoverArtist)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Publishers
.ForMember(dest => dest.Publishers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Publisher)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Characters
.ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Character)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Pencillers
.ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Penciller)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Inkers
.ForMember(dest => dest.Inkers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Inker)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Imprints
.ForMember(dest => dest.Imprints, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Imprint)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Colorists
.ForMember(dest => dest.Colorists, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Colorist)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Letterers
.ForMember(dest => dest.Letterers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Letterer)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Editors
.ForMember(dest => dest.Editors, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Editor)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Translators
.ForMember(dest => dest.Translators, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Translator)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Teams
.ForMember(dest => dest.Teams, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Team)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
// Map Locations
.ForMember(dest => dest.Locations, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Location)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)));
CreateMap<AppUser, UserDto>()
.ForMember(dest => dest.AgeRestriction,
@ -337,5 +335,11 @@ public class AutoMapperProfiles : Profile
CreateMap<MangaFile, FileExtensionExportDto>();
CreateMap<Chapter, StandaloneChapterDto>()
.ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.Volume.SeriesId))
.ForMember(dest => dest.VolumeTitle, opt => opt.MapFrom(src => src.Volume.Name))
.ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId))
.ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type));
}
}

View file

@ -142,4 +142,17 @@ public class ChapterBuilder : IEntityBuilder<Chapter>
_chapter.CreatedUtc = created.ToUniversalTime();
return this;
}
public ChapterBuilder WithPerson(Person person, PersonRole role)
{
_chapter.People ??= new List<ChapterPeople>();
_chapter.People.Add(new ChapterPeople()
{
Person = person,
Role = role,
Chapter = _chapter,
});
return this;
}
}

View file

@ -11,15 +11,14 @@ public class PersonBuilder : IEntityBuilder<Person>
private readonly Person _person;
public Person Build() => _person;
public PersonBuilder(string name, PersonRole role)
public PersonBuilder(string name)
{
_person = new Person()
{
Name = name.Trim(),
NormalizedName = name.ToNormalized(),
Role = role,
ChapterMetadatas = new List<Chapter>(),
SeriesMetadatas = new List<SeriesMetadata>()
SeriesMetadataPeople = new List<SeriesMetadataPeople>(),
ChapterPeople = new List<ChapterPeople>()
};
}
@ -34,10 +33,10 @@ public class PersonBuilder : IEntityBuilder<Person>
return this;
}
public PersonBuilder WithSeriesMetadata(SeriesMetadata metadata)
public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople)
{
_person.SeriesMetadatas ??= new List<SeriesMetadata>();
_person.SeriesMetadatas.Add(metadata);
_person.SeriesMetadataPeople.Add(seriesMetadataPeople);
return this;
}
}

View file

@ -17,7 +17,7 @@ public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
CollectionTags = new List<CollectionTag>(),
Genres = new List<Genre>(),
Tags = new List<Tag>(),
People = new List<Person>()
People = new List<SeriesMetadataPeople>()
};
}
@ -45,4 +45,17 @@ public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
_seriesMetadata.AgeRating = rating;
return this;
}
public SeriesMetadataBuilder WithPerson(Person person, PersonRole role)
{
_seriesMetadata.People ??= new List<SeriesMetadataPeople>();
_seriesMetadata.People.Add(new SeriesMetadataPeople()
{
Role = role,
Person = person,
SeriesMetadata = _seriesMetadata,
});
return this;
}
}

View file

@ -14,8 +14,8 @@ public interface ICacheHelper
bool CoverImageExists(string path);
bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile);
bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile);
bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile? firstFile);
bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile? firstFile);
}
@ -56,7 +56,7 @@ public class CacheHelper : ICacheHelper
/// <param name="forceUpdate"></param>
/// <param name="firstFile"></param>
/// <returns></returns>
public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile)
public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile? firstFile)
{
return firstFile != null &&
(!forceUpdate &&
@ -71,7 +71,7 @@ public class CacheHelper : ICacheHelper
/// <param name="forceUpdate">Should we ignore any logic and force this to return true</param>
/// <param name="firstFile">The file in question</param>
/// <returns></returns>
public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile)
public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile? firstFile)
{
if (firstFile == null) return false;
if (forceUpdate) return true;

View file

@ -1,153 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Metadata;
using API.Entities;
using API.Extensions;
using API.Helpers.Builders;
using Microsoft.EntityFrameworkCore;
namespace API.Helpers;
#nullable enable
public static class GenreHelper
{
public static void UpdateGenre(Dictionary<string, Genre> allGenres,
IEnumerable<string> names, Action<Genre, bool> action)
public static async Task UpdateChapterGenres(Chapter chapter, IEnumerable<string> genreNames, IUnitOfWork unitOfWork)
{
foreach (var name in names)
{
var normalizedName = name.ToNormalized();
if (string.IsNullOrEmpty(normalizedName)) continue;
// Normalize genre names once and store them in a hash set for quick lookups
var normalizedGenresToAdd = new HashSet<string>(genreNames.Select(g => g.ToNormalized()));
if (allGenres.TryGetValue(normalizedName, out var genre))
// Remove genres that are no longer in the new list
var genresToRemove = chapter.Genres
.Where(g => !normalizedGenresToAdd.Contains(g.NormalizedTitle))
.ToList();
if (genresToRemove.Count > 0)
{
foreach (var genreToRemove in genresToRemove)
{
action(genre, false);
chapter.Genres.Remove(genreToRemove);
}
else
}
// Get all normalized titles to query the database for existing genres
var existingGenreTitles = await unitOfWork.DataContext.Genre
.Where(g => normalizedGenresToAdd.Contains(g.NormalizedTitle))
.ToDictionaryAsync(g => g.NormalizedTitle);
// Find missing genres that are not in the database
var missingGenres = normalizedGenresToAdd
.Where(nt => !existingGenreTitles.ContainsKey(nt))
.Select(title => new GenreBuilder(title).Build())
.ToList();
// Add missing genres to the database
if (missingGenres.Count > 0)
{
unitOfWork.DataContext.Genre.AddRange(missingGenres);
await unitOfWork.CommitAsync();
// Add newly inserted genres to existing genres dictionary for easier lookup
foreach (var genre in missingGenres)
{
genre = new GenreBuilder(name).Build();
allGenres.Add(normalizedName, genre);
action(genre, true);
existingGenreTitles[genre.NormalizedTitle] = genre;
}
}
// Add genres that are either existing or newly added to the chapter
foreach (var normalizedTitle in normalizedGenresToAdd)
{
var genre = existingGenreTitles[normalizedTitle];
if (!chapter.Genres.Contains(genre))
{
chapter.Genres.Add(genre);
}
}
}
public static void KeepOnlySameGenreBetweenLists(ICollection<Genre> existingGenres, ICollection<Genre> removeAllExcept, Action<Genre>? action = null)
{
var existing = existingGenres.ToList();
foreach (var genre in existing)
{
var existingPerson = removeAllExcept.FirstOrDefault(g => genre.NormalizedTitle != null && genre.NormalizedTitle.Equals(g.NormalizedTitle));
if (existingPerson != null) continue;
existingGenres.Remove(genre);
action?.Invoke(genre);
}
}
/// <summary>
/// Adds the genre to the list if it's not already in there.
/// </summary>
/// <param name="metadataGenres"></param>
/// <param name="genre"></param>
public static void AddGenreIfNotExists(ICollection<Genre> metadataGenres, Genre genre)
{
var existingGenre = metadataGenres.FirstOrDefault(p =>
p.NormalizedTitle.Equals(genre.Title?.ToNormalized()));
if (existingGenre == null)
{
metadataGenres.Add(genre);
}
}
public static void UpdateGenreList(ICollection<GenreTagDto>? tags, Series series,
IReadOnlyCollection<Genre> allTags, Action<Genre> handleAdd, Action onModified)
public static void UpdateGenreList(ICollection<GenreTagDto>? existingGenres, Series series,
IReadOnlyCollection<Genre> newGenres, Action<Genre> handleAdd, Action onModified)
{
// TODO: Write some unit tests
if (tags == null) return;
if (existingGenres == null) return;
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.Genres.ToList();
// Convert tags and existing genres to hash sets for quick lookups by normalized title
var tagSet = new HashSet<string>(existingGenres.Select(t => t.Title.ToNormalized()));
var genreSet = new HashSet<string>(series.Metadata.Genres.Select(g => g.NormalizedTitle));
// Remove tags that are no longer present in the input tags
var existingTags = series.Metadata.Genres.ToList(); // Copy to avoid modifying collection while iterating
foreach (var existing in existingTags)
{
if (tags.SingleOrDefault(t => t.Title.ToNormalized().Equals(existing.NormalizedTitle)) == null)
if (!tagSet.Contains(existing.NormalizedTitle)) // This correctly ensures removal of non-present tags
{
// Remove tag
series.Metadata.Genres.Remove(existing);
isModified = true;
}
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tagTitle in tags.Select(t => t.Title))
// Prepare a dictionary for quick lookup of genres from the `newGenres` collection by normalized title
var allTagsDict = newGenres.ToDictionary(t => t.NormalizedTitle);
// Add new tags from the input list
foreach (var tagDto in existingGenres)
{
var normalizedTitle = tagTitle.ToNormalized();
var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle));
if (existingTag != null)
var normalizedTitle = tagDto.Title.ToNormalized();
if (!genreSet.Contains(normalizedTitle)) // This prevents re-adding existing genres
{
if (series.Metadata.Genres.All(t => !t.NormalizedTitle.Equals(normalizedTitle)))
if (allTagsDict.TryGetValue(normalizedTitle, out var existingTag))
{
handleAdd(existingTag);
isModified = true;
handleAdd(existingTag); // Add existing tag from allTagsDict
}
}
else
{
// Add new tag
handleAdd(new GenreBuilder(tagTitle).Build());
isModified = true;
}
}
if (isModified)
{
onModified();
}
}
public static void UpdateGenreList(ICollection<GenreTagDto>? tags, Chapter chapter,
IReadOnlyCollection<Genre> allTags, Action<Genre> handleAdd, Action onModified)
{
// TODO: Write some unit tests
if (tags == null) return;
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = chapter.Genres.ToList();
foreach (var existing in existingTags)
{
if (tags.SingleOrDefault(t => t.Title.ToNormalized().Equals(existing.NormalizedTitle)) == null)
{
// Remove tag
chapter.Genres.Remove(existing);
isModified = true;
}
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tagTitle in tags.Select(t => t.Title))
{
var normalizedTitle = tagTitle.ToNormalized();
var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle));
if (existingTag != null)
{
if (chapter.Genres.All(t => !t.NormalizedTitle.Equals(normalizedTitle)))
else
{
handleAdd(existingTag);
isModified = true;
handleAdd(new GenreBuilder(tagDto.Title).Build()); // Add new genre if not found
}
}
else
{
// Add new tag
handleAdd(new GenreBuilder(tagTitle).Build());
isModified = true;
}
}
// Call onModified if any changes were made
if (isModified)
{
onModified();

View file

@ -1,210 +1,190 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers.Builders;
namespace API.Helpers;
#nullable enable
// This isn't needed in the new person architecture
public static class PersonHelper
{
/// <summary>
/// Given a list of all existing people, this will check the new names and roles and if it doesn't exist in allPeople, will create and
/// add an entry. For each person in name, the callback will be executed.
/// </summary>
/// <remarks>This does not remove people if an empty list is passed into names</remarks>
/// <remarks>This is used to add new people to a list without worrying about duplicating rows in the DB</remarks>
/// <param name="allPeople"></param>
/// <param name="names"></param>
/// <param name="role"></param>
/// <param name="action"></param>
public static void UpdatePeople(ICollection<Person> allPeople, IEnumerable<string> names, PersonRole role, Action<Person> action)
public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection<SeriesMetadataPeople> metadataPeople,
IEnumerable<ChapterPeople> chapterPeople, PersonRole role, IUnitOfWork unitOfWork)
{
var allPeopleTypeRole = allPeople.Where(p => p.Role == role).ToList();
var modification = false;
foreach (var name in names)
// Get all normalized names of people with the specified role from chapterPeople
var peopleToAdd = chapterPeople
.Where(cp => cp.Role == role)
.Select(cp => cp.Person.NormalizedName)
.ToList();
// Prepare a HashSet for quick lookup of people to add
var peopleToAddSet = new HashSet<string>(peopleToAdd);
// Get all existing people from metadataPeople with the specified role
var existingMetadataPeople = metadataPeople
.Where(mp => mp.Role == role)
.ToList();
// Identify people to remove from metadataPeople
var peopleToRemove = existingMetadataPeople
.Where(person => !peopleToAddSet.Contains(person.Person.NormalizedName))
.ToList();
// Remove identified people from metadataPeople
foreach (var personToRemove in peopleToRemove)
{
var normalizedName = name.ToNormalized();
// BUG: Doesn't this create a duplicate entry because allPeopleTypeRoles is a different instance?
var person = allPeopleTypeRole.Find(p =>
p.NormalizedName != null && p.NormalizedName.Equals(normalizedName));
if (person == null)
{
person = new PersonBuilder(name, role).Build();
allPeople.Add(person);
}
metadataPeople.Remove(personToRemove);
modification = true;
}
action(person);
// Bulk fetch existing people from the repository
var existingPeopleInDb = await unitOfWork.PersonRepository
.GetPeopleByNames(peopleToAdd);
// 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);
}
// Track the people to attach (newly created people)
var peopleToAttach = new List<Person>();
// Identify new people (not already in metadataPeople) to add
foreach (var personName in peopleToAdd)
{
// Check if the person already exists in metadataPeople with the specific role
var personAlreadyInMetadata = metadataPeople
.Any(mp => mp.Person.NormalizedName == personName && mp.Role == role);
if (!personAlreadyInMetadata)
{
// Check if the person exists in the database
if (!existingPeopleDict.TryGetValue(personName, out var dbPerson))
{
// If not, create a new Person entity
dbPerson = new PersonBuilder(personName).Build();
peopleToAttach.Add(dbPerson); // Add new person to the list to be attached
modification = true;
}
// Add the person to the SeriesMetadataPeople collection
metadataPeople.Add(new SeriesMetadataPeople
{
PersonId = dbPerson.Id, // EF Core will automatically update this after attach
Person = dbPerson,
SeriesMetadataId = metadata.Id,
SeriesMetadata = metadata,
Role = role
});
modification = true;
}
}
// Attach all new people in one go (EF Core will assign IDs after commit)
if (peopleToAttach.Count != 0)
{
await unitOfWork.DataContext.Person.AddRangeAsync(peopleToAttach);
}
// Commit the changes if any modifications were made
if (modification)
{
await unitOfWork.CommitAsync();
}
}
/// <summary>
/// Remove people on a list for a given role
/// </summary>
/// <remarks>Used to remove before we update/add new people</remarks>
/// <param name="existingPeople">Existing people on Entity</param>
/// <param name="people">People from metadata</param>
/// <param name="role">Role to filter on</param>
/// <param name="action">Callback which will be executed for each person removed</param>
public static void RemovePeople(ICollection<Person> existingPeople, IEnumerable<string> people, PersonRole role, Action<Person>? action = null)
public static async Task UpdateChapterPeopleAsync(Chapter chapter, IList<string> people, PersonRole role, IUnitOfWork unitOfWork)
{
var normalizedPeople = people.Select(Services.Tasks.Scanner.Parser.Parser.Normalize).ToList();
if (normalizedPeople.Count == 0)
{
var peopleToRemove = existingPeople.Where(p => p.Role == role).ToList();
foreach (var existingRoleToRemove in peopleToRemove)
{
existingPeople.Remove(existingRoleToRemove);
action?.Invoke(existingRoleToRemove);
}
return;
}
var modification = false;
foreach (var person in normalizedPeople)
{
var existingPerson = existingPeople.FirstOrDefault(p => p.Role == role && person.Equals(p.NormalizedName));
if (existingPerson == null) continue;
// Normalize the input names for comparison
var normalizedPeople = people.Select(p => p.ToNormalized()).Distinct().ToList(); // Ensure distinct people
existingPeople.Remove(existingPerson);
action?.Invoke(existingPerson);
}
// Get all existing ChapterPeople for the role
var existingChapterPeople = chapter.People
.Where(cp => cp.Role == role)
.ToList();
}
// Prepare a hash set for quick lookup of existing people by name
var existingPeopleNames = new HashSet<string>(existingChapterPeople.Select(cp => cp.Person.NormalizedName));
/// <summary>
/// Removes all people that are not present in the removeAllExcept list.
/// </summary>
/// <param name="existingPeople"></param>
/// <param name="removeAllExcept"></param>
/// <param name="action">Callback for all entities that should be removed</param>
public static void KeepOnlySamePeopleBetweenLists(IEnumerable<Person> existingPeople, ICollection<Person> removeAllExcept, Action<Person>? action = null)
{
// Bulk select all people from the repository whose names are in the provided list
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)
{
var existingPerson = removeAllExcept
.FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName));
if (existingPerson == null)
{
action?.Invoke(person);
}
}
}
/// <summary>
/// Adds the person to the list if it's not already in there
/// </summary>
/// <param name="metadataPeople"></param>
/// <param name="person"></param>
public static void AddPersonIfNotExists(ICollection<Person> metadataPeople, Person person)
{
if (string.IsNullOrEmpty(person.Name)) return;
var existingPerson = metadataPeople.FirstOrDefault(p =>
p.NormalizedName == person.Name.ToNormalized() && p.Role == person.Role);
if (existingPerson == null)
{
metadataPeople.Add(person);
}
}
/// <summary>
/// For a given role and people dtos, update a series
/// </summary>
/// <param name="role"></param>
/// <param name="people"></param>
/// <param name="series"></param>
/// <param name="allPeople"></param>
/// <param name="handleAdd">This will call with an existing or new tag, but the method does not update the series Metadata</param>
/// <param name="onModified"></param>
public static void UpdatePeopleList(PersonRole role, ICollection<PersonDto>? people, Series series, IReadOnlyCollection<Person> allPeople,
Action<Person> handleAdd, Action onModified)
{
if (people == null) return;
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList();
foreach (var existing in existingTags)
{
if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role
{
// Remove tag
series.Metadata.People.Remove(existing);
isModified = true;
}
existingPeopleDict.TryAdd(person.NormalizedName, person);
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tag in people)
// 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 existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role);
if (existingTag != null)
{
if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name)))
{
handleAdd(existingTag);
isModified = true;
}
}
else
{
// Add new tag
handleAdd(new PersonBuilder(tag.Name, role).Build());
isModified = true;
}
chapter.People.Remove(existingChapterPerson);
unitOfWork.PersonRepository.Remove(existingChapterPerson);
modification = true;
}
if (isModified)
{
onModified();
}
}
// Identify new people to add
var newPeopleNames = normalizedPeople
.Where(p => !existingPeopleNames.Contains(p))
.ToList();
public static void UpdatePeopleList(PersonRole role, ICollection<PersonDto>? people, Chapter chapter, IReadOnlyCollection<Person> allPeople,
Action<Person> handleAdd, Action onModified)
{
if (people == null) return;
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = chapter.People.Where(p => p.Role == role).ToList();
foreach (var existing in existingTags)
if (newPeopleNames.Count > 0)
{
if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role
// Bulk insert new people (if they don't already exist in the database)
var newPeople = newPeopleNames
.Where(name => !existingPeopleDict.ContainsKey(name)) // Avoid adding duplicates
.Select(name => new PersonBuilder(name).Build())
.ToList();
foreach (var newPerson in newPeople)
{
// Remove tag
chapter.People.Remove(existing);
isModified = true;
unitOfWork.DataContext.Person.Attach(newPerson);
existingPeopleDict[newPerson.NormalizedName] = newPerson;
}
await unitOfWork.CommitAsync();
modification = true;
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tag in people)
// Add all people (both existing and newly created) to the ChapterPeople
foreach (var personName in normalizedPeople)
{
var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role);
if (existingTag != null)
var person = existingPeopleDict[personName];
// Check if the person with the specific role is already added to the chapter's People collection
if (chapter.People.Any(cp => cp.PersonId == person.Id && cp.Role == role)) continue;
chapter.People.Add(new ChapterPeople
{
if (chapter.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name)))
{
handleAdd(existingTag);
isModified = true;
}
}
else
{
// Add new tag
handleAdd(new PersonBuilder(tag.Name, role).Build());
isModified = true;
}
PersonId = person.Id,
ChapterId = chapter.Id,
Role = role
});
modification = true;
}
if (isModified)
// Commit the changes to remove and add people
if (modification)
{
onModified();
await unitOfWork.CommitAsync();
}
}
@ -220,7 +200,9 @@ public static class PersonHelper
dto.Colorists.Count != 0 ||
dto.Letterers.Count != 0 ||
dto.Editors.Count != 0 ||
dto.Translators.Count != 0;
dto.Translators.Count != 0 ||
dto.Teams.Count != 0 ||
dto.Locations.Count != 0;
}
public static bool HasAnyPeople(UpdateChapterDto? dto)
@ -235,6 +217,8 @@ public static class PersonHelper
dto.Colorists.Count != 0 ||
dto.Letterers.Count != 0 ||
dto.Editors.Count != 0 ||
dto.Translators.Count != 0;
dto.Translators.Count != 0 ||
dto.Teams.Count != 0 ||
dto.Locations.Count != 0;
}
}

View file

@ -1,198 +1,147 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Metadata;
using API.Entities;
using API.Extensions;
using API.Helpers.Builders;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.EntityFrameworkCore;
namespace API.Helpers;
#nullable enable
public static class TagHelper
{
public static void UpdateTag(Dictionary<string, Tag> allTags, IEnumerable<string> names, Action<Tag, bool> action)
public static async Task UpdateChapterTags(Chapter chapter, IEnumerable<string> tagNames, IUnitOfWork unitOfWork)
{
foreach (var name in names)
// Normalize tag names once and store them in a hash set for quick lookups
var normalizedTagsToAdd = new HashSet<string>(tagNames.Select(t => t.ToNormalized()));
var existingTagsSet = new HashSet<string>(chapter.Tags.Select(t => t.NormalizedTitle));
var isModified = false;
// Remove tags that are no longer present in the new list
var tagsToRemove = chapter.Tags
.Where(t => !normalizedTagsToAdd.Contains(t.NormalizedTitle))
.ToList();
if (tagsToRemove.Any())
{
if (string.IsNullOrEmpty(name.Trim())) continue;
var normalizedName = name.ToNormalized();
allTags.TryGetValue(normalizedName, out var tag);
var added = tag == null;
if (tag == null)
foreach (var tagToRemove in tagsToRemove)
{
tag = new TagBuilder(name).Build();
allTags.Add(normalizedName, tag);
chapter.Tags.Remove(tagToRemove);
}
action(tag, added);
isModified = true;
}
}
public static void KeepOnlySameTagBetweenLists(ICollection<Tag> existingTags, ICollection<Tag> removeAllExcept, Action<Tag>? action = null)
{
var existing = existingTags.ToList();
foreach (var genre in existing)
// Get all normalized titles for bulk lookup from the database
var existingTagTitles = await unitOfWork.DataContext.Tag
.Where(t => normalizedTagsToAdd.Contains(t.NormalizedTitle))
.ToDictionaryAsync(t => t.NormalizedTitle);
// Find missing tags that are not already in the database
var missingTags = normalizedTagsToAdd
.Where(nt => !existingTagTitles.ContainsKey(nt))
.Select(title => new TagBuilder(title).Build())
.ToList();
// Add missing tags to the database if any
if (missingTags.Any())
{
var existingPerson = removeAllExcept.FirstOrDefault(g => genre.NormalizedTitle.Equals(g.NormalizedTitle));
if (existingPerson != null) continue;
existingTags.Remove(genre);
action?.Invoke(genre);
unitOfWork.DataContext.Tag.AddRange(missingTags);
await unitOfWork.CommitAsync(); // Commit once after adding missing tags to avoid multiple DB calls
isModified = true;
// Update the dictionary with newly inserted tags for easier lookup
foreach (var tag in missingTags)
{
existingTagTitles[tag.NormalizedTitle] = tag;
}
}
// Add the new or existing tags to the chapter
foreach (var normalizedTitle in normalizedTagsToAdd)
{
var tag = existingTagTitles[normalizedTitle];
if (!existingTagsSet.Contains(normalizedTitle))
{
chapter.Tags.Add(tag);
isModified = true;
}
}
// Commit changes if modifications were made to the chapter's tags
if (isModified)
{
await unitOfWork.CommitAsync();
}
}
/// <summary>
/// Adds the tag to the list if it's not already in there. This will ignore the ExternalTag.
/// Returns a list of strings separated by ',', distinct by normalized names, already trimmed and empty entries removed.
/// </summary>
/// <param name="metadataTags"></param>
/// <param name="tag"></param>
public static void AddTagIfNotExists(ICollection<Tag> metadataTags, Tag tag)
{
var existingGenre = metadataTags.FirstOrDefault(p =>
p.NormalizedTitle == tag.Title.ToNormalized());
if (existingGenre == null)
{
metadataTags.Add(tag);
}
}
public static void AddTagIfNotExists(BlockingCollection<Tag> metadataTags, Tag tag)
{
var existingGenre = metadataTags.FirstOrDefault(p =>
p.NormalizedTitle == tag.Title.ToNormalized());
if (existingGenre == null)
{
metadataTags.Add(tag);
}
}
/// <param name="comicInfoTagSeparatedByComma"></param>
/// <returns></returns>
public static IList<string> GetTagValues(string comicInfoTagSeparatedByComma)
{
// TODO: Unit tests needed
// TODO: Refactor this into an Extension
if (string.IsNullOrEmpty(comicInfoTagSeparatedByComma))
{
return ImmutableList<string>.Empty;
}
return comicInfoTagSeparatedByComma.Split(",")
.Select(s => s.Trim())
return comicInfoTagSeparatedByComma.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.DistinctBy(Parser.Normalize)
.ToList();
}
/// <summary>
/// Remove tags on a list
/// </summary>
/// <remarks>Used to remove before we update/add new tags</remarks>
/// <param name="existingTags">Existing tags on Entity</param>
/// <param name="tags">Tags from metadata</param>
/// <param name="action">Callback which will be executed for each tag removed</param>
public static void RemoveTags(ICollection<Tag> existingTags, IEnumerable<string> tags, Action<Tag>? action = null)
{
var normalizedTags = tags.Select(Services.Tasks.Scanner.Parser.Parser.Normalize).ToList();
foreach (var person in normalizedTags)
{
var existingTag = existingTags.FirstOrDefault(p => person.Equals(p.NormalizedTitle));
if (existingTag == null) continue;
existingTags.Remove(existingTag);
action?.Invoke(existingTag);
}
}
public static void UpdateTagList(ICollection<TagDto>? tags, Series series, IReadOnlyCollection<Tag> allTags, Action<Tag> handleAdd, Action onModified)
{
if (tags == null) return;
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.Tags.ToList();
foreach (var existing in existingTags.Where(existing => tags.SingleOrDefault(t => t.Id == existing.Id) == null))
var existingTags = series.Metadata.Tags;
// Create a HashSet for quick lookup of tag IDs
var tagIds = new HashSet<int>(tags.Select(t => t.Id));
// Remove tags that no longer exist in the provided tag list
var tagsToRemove = existingTags.Where(existing => !tagIds.Contains(existing.Id)).ToList();
if (tagsToRemove.Count > 0)
{
// Remove tag
series.Metadata.Tags.Remove(existing);
foreach (var tagToRemove in tagsToRemove)
{
existingTags.Remove(tagToRemove);
}
isModified = true;
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tagTitle in tags.Select(t => t.Title))
{
var normalizedTitle = tagTitle.ToNormalized();
var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle));
if (existingTag != null)
{
if (series.Metadata.Tags.All(t => t.NormalizedTitle != normalizedTitle))
{
// Create a HashSet of normalized titles for quick lookups
var normalizedTitlesToAdd = new HashSet<string>(tags.Select(t => t.Title.ToNormalized()));
var existingNormalizedTitles = new HashSet<string>(existingTags.Select(t => t.NormalizedTitle));
handleAdd(existingTag);
isModified = true;
}
}
else
{
// Add new tag
handleAdd(new TagBuilder(tagTitle).Build());
isModified = true;
}
// Add missing tags based on normalized title comparison
foreach (var normalizedTitle in normalizedTitlesToAdd)
{
if (existingNormalizedTitles.Contains(normalizedTitle)) continue;
var existingTag = allTags.FirstOrDefault(t => t.NormalizedTitle == normalizedTitle);
handleAdd(existingTag ?? new TagBuilder(normalizedTitle).Build());
isModified = true;
}
// Call the modification handler if any changes were made
if (isModified)
{
onModified();
}
}
public static void UpdateTagList(ICollection<TagDto>? tags, Chapter chapter, IReadOnlyCollection<Tag> allTags, Action<Tag> handleAdd, Action onModified)
{
if (tags == null) return;
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = chapter.Tags.ToList();
foreach (var existing in existingTags.Where(existing => tags.SingleOrDefault(t => t.Id == existing.Id) == null))
{
// Remove tag
chapter.Tags.Remove(existing);
isModified = true;
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tagTitle in tags.Select(t => t.Title))
{
var normalizedTitle = tagTitle.ToNormalized();
var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle));
if (existingTag != null)
{
if (chapter.Tags.All(t => t.NormalizedTitle != normalizedTitle))
{
handleAdd(existingTag);
isModified = true;
}
}
else
{
// Add new tag
handleAdd(new TagBuilder(tagTitle).Build());
isModified = true;
}
}
if (isModified)
{
onModified();
}
}
}
#nullable disable