Collection Rework (#2830)

This commit is contained in:
Joe Milazzo 2024-04-06 12:03:49 -05:00 committed by GitHub
parent 0dacc061f1
commit deaaccb96a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 5413 additions and 1120 deletions

View file

@ -1,13 +1,12 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.CollectionTags;
using API.DTOs.Collection;
using API.Entities;
using API.Entities.Metadata;
using API.Helpers.Builders;
using API.Extensions;
using API.Services.Plus;
using API.SignalR;
using Kavita.Common;
@ -16,15 +15,9 @@ namespace API.Services;
public interface ICollectionTagService
{
Task<bool> TagExistsByName(string name);
Task<bool> DeleteTag(CollectionTag tag);
Task<bool> UpdateTag(CollectionTagDto dto);
Task<bool> AddTagToSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
Task<CollectionTag> GetTagOrCreate(int tagId, string title);
void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata);
CollectionTag CreateTag(string title);
Task<bool> RemoveTagsWithoutSeries();
Task<bool> DeleteTag(int tagId, AppUser user);
Task<bool> UpdateTag(AppUserCollectionDto dto, int userId);
Task<bool> RemoveTagFromSeries(AppUserCollection? tag, IEnumerable<int> seriesIds);
}
@ -39,37 +32,44 @@ public class CollectionTagService : ICollectionTagService
_eventHub = eventHub;
}
/// <summary>
/// Checks if a collection exists with the name
/// </summary>
/// <param name="name">If empty or null, will return true as that is invalid</param>
/// <returns></returns>
public async Task<bool> TagExistsByName(string name)
public async Task<bool> DeleteTag(int tagId, AppUser user)
{
if (string.IsNullOrEmpty(name.Trim())) return true;
return await _unitOfWork.CollectionTagRepository.TagExists(name);
}
var collectionTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(tagId);
if (collectionTag == null) return true;
user.Collections.Remove(collectionTag);
if (!_unitOfWork.HasChanges()) return true;
public async Task<bool> DeleteTag(CollectionTag tag)
{
_unitOfWork.CollectionTagRepository.Remove(tag);
return await _unitOfWork.CommitAsync();
}
public async Task<bool> UpdateTag(CollectionTagDto dto)
public async Task<bool> UpdateTag(AppUserCollectionDto dto, int userId)
{
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id);
var existingTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(dto.Id);
if (existingTag == null) throw new KavitaException("collection-doesnt-exist");
if (existingTag.AppUserId != userId) throw new KavitaException("access-denied");
var title = dto.Title.Trim();
if (string.IsNullOrEmpty(title)) throw new KavitaException("collection-tag-title-required");
if (!title.Equals(existingTag.Title) && await TagExistsByName(dto.Title))
// Ensure the title doesn't exist on the user's account already
if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, userId))
throw new KavitaException("collection-tag-duplicate");
existingTag.SeriesMetadatas ??= new List<SeriesMetadata>();
existingTag.Title = title;
existingTag.NormalizedTitle = Tasks.Scanner.Parser.Parser.Normalize(dto.Title);
existingTag.Promoted = dto.Promoted;
existingTag.Items ??= new List<Series>();
if (existingTag.Source == ScrobbleProvider.Kavita)
{
existingTag.Title = title;
existingTag.NormalizedTitle = dto.Title.ToNormalized();
}
var roles = await _unitOfWork.UserRepository.GetRoles(userId);
if (roles.Contains(PolicyConstants.AdminRole) || roles.Contains(PolicyConstants.PromoteRole))
{
existingTag.Promoted = dto.Promoted;
}
existingTag.CoverImageLocked = dto.CoverImageLocked;
_unitOfWork.CollectionTagRepository.Update(existingTag);
@ -96,89 +96,31 @@ public class CollectionTagService : ICollectionTagService
}
/// <summary>
/// Adds a set of Series to a Collection
/// Removes series from Collection tag. Will recalculate max age rating.
/// </summary>
/// <param name="tag">A full Tag</param>
/// <param name="tag"></param>
/// <param name="seriesIds"></param>
/// <returns></returns>
public async Task<bool> AddTagToSeries(CollectionTag? tag, IEnumerable<int> seriesIds)
public async Task<bool> RemoveTagFromSeries(AppUserCollection? tag, IEnumerable<int> seriesIds)
{
if (tag == null) return false;
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(seriesIds);
foreach (var metadata in metadatas)
{
AddTagToSeriesMetadata(tag, metadata);
}
if (!_unitOfWork.HasChanges()) return true;
return await _unitOfWork.CommitAsync();
}
tag.Items ??= new List<Series>();
tag.Items = tag.Items.Where(s => !seriesIds.Contains(s.Id)).ToList();
/// <summary>
/// Adds a collection tag to a SeriesMetadata
/// </summary>
/// <remarks>Does not commit</remarks>
/// <param name="tag"></param>
/// <param name="metadata"></param>
/// <returns></returns>
public void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata)
{
if (tag == null) return;
metadata.CollectionTags ??= new List<CollectionTag>();
if (metadata.CollectionTags.Any(t => t.NormalizedTitle.Equals(tag.NormalizedTitle, StringComparison.InvariantCulture))) return;
metadata.CollectionTags.Add(tag);
if (metadata.Id != 0)
{
_unitOfWork.SeriesMetadataRepository.Update(metadata);
}
}
public async Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds)
{
if (tag == null) return false;
tag.SeriesMetadatas ??= new List<SeriesMetadata>();
foreach (var seriesIdToRemove in seriesIds)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
}
if (tag.SeriesMetadatas.Count == 0)
if (tag.Items.Count == 0)
{
_unitOfWork.CollectionTagRepository.Remove(tag);
}
if (!_unitOfWork.HasChanges()) return true;
return await _unitOfWork.CommitAsync();
}
var result = await _unitOfWork.CommitAsync();
if (tag.Items.Count > 0)
{
await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(tag);
}
/// <summary>
/// Tries to fetch the full tag, else returns a new tag. Adds to tracking but does not commit
/// </summary>
/// <param name="tagId"></param>
/// <param name="title"></param>
/// <returns></returns>
public async Task<CollectionTag> GetTagOrCreate(int tagId, string title)
{
return await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata) ?? CreateTag(title);
}
/// <summary>
/// This just creates the entity and adds to tracking. Use <see cref="GetTagOrCreate"/> for checks of duplication.
/// </summary>
/// <param name="title"></param>
/// <returns></returns>
public CollectionTag CreateTag(string title)
{
var tag = new CollectionTagBuilder(title).Build();
_unitOfWork.CollectionTagRepository.Add(tag);
return tag;
}
public async Task<bool> RemoveTagsWithoutSeries()
{
return await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries() > 0;
return result;
}
}

View file

@ -278,7 +278,7 @@ public class MetadataService : IMetadataService
await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated();
await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated();
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries();
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
}

View file

@ -115,12 +115,6 @@ public class SeriesService : ISeriesService
if (series == null) return false;
series.Metadata ??= new SeriesMetadataBuilder()
.WithCollectionTags(updateSeriesMetadataDto.CollectionTags.Select(dto =>
new CollectionTagBuilder(dto.Title)
.WithId(dto.Id)
.WithSummary(dto.Summary)
.WithIsPromoted(dto.Promoted)
.Build()).ToList())
.Build();
if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating)
@ -163,28 +157,16 @@ public class SeriesService : ISeriesService
series.Metadata.WebLinks = string.Empty;
} else
{
series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks
.Split(",")
series.Metadata.WebLinks = string.Join(',', updateSeriesMetadataDto.SeriesMetadata?.WebLinks
.Split(',')
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => s.Trim())!
);
}
if (updateSeriesMetadataDto.CollectionTags.Count > 0)
{
var allCollectionTags = (await _unitOfWork.CollectionTagRepository
.GetAllTagsByNamesAsync(updateSeriesMetadataDto.CollectionTags.Select(t => Parser.Normalize(t.Title)))).ToList();
series.Metadata.CollectionTags ??= new List<CollectionTag>();
UpdateCollectionsList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, tag =>
{
series.Metadata.CollectionTags.Add(tag);
});
}
if (updateSeriesMetadataDto.SeriesMetadata?.Genres != null &&
updateSeriesMetadataDto.SeriesMetadata.Genres.Any())
updateSeriesMetadataDto.SeriesMetadata.Genres.Count != 0)
{
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(updateSeriesMetadataDto.SeriesMetadata.Genres.Select(t => Parser.Normalize(t.Title)))).ToList();
series.Metadata.Genres ??= new List<Genre>();
@ -320,12 +302,6 @@ public class SeriesService : ISeriesService
_logger.LogError(ex, "There was an issue cleaning up DB entries. This may happen if Komf is spamming updates. Nightly cleanup will work");
}
if (updateSeriesMetadataDto.CollectionTags == null) return true;
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
{
await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection,
MessageFactory.SeriesAddedToCollectionEvent(tag.Id, seriesId), false);
}
return true;
}
catch (Exception ex)
@ -337,46 +313,6 @@ public class SeriesService : ISeriesService
return false;
}
private static void UpdateCollectionsList(ICollection<CollectionTagDto>? tags, Series series, IReadOnlyCollection<CollectionTag> allTags,
Action<CollectionTag> handleAdd)
{
// TODO: Move UpdateCollectionsList to a helper so we can easily test
if (tags == null) return;
// 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.CollectionTags.ToList();
foreach (var existing in existingTags)
{
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null)
{
// Remove tag
series.Metadata.CollectionTags.Remove(existing);
}
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tag in tags)
{
var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
if (existingTag != null)
{
if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title))
{
handleAdd(existingTag);
}
}
else
{
// Add new tag
handleAdd(new CollectionTagBuilder(tag.Title)
.WithId(tag.Id)
.WithSummary(tag.Summary)
.WithIsPromoted(tag.Promoted)
.Build());
}
}
}
/// <summary>
///
/// </summary>
@ -461,7 +397,7 @@ public class SeriesService : ISeriesService
}
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries();
_taskScheduler.CleanupChapters(allChapterIds.ToArray());
return true;
}

View file

@ -107,7 +107,7 @@ public class CleanupService : ICleanupService
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated();
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries();
await _unitOfWork.ReadingListRepository.RemoveReadingListsWithoutSeries();
}

View file

@ -467,14 +467,13 @@ public class ParseScannedFiles
}
chapters = infos
.OrderByNatural(info => info.Chapters)
.ToList();
// If everything is a special but we don't have any SpecialIndex, then order naturally and use 0, 1, 2
if (specialTreatment)
{
chapters = infos
.OrderByNatural(info => Parser.Parser.RemoveExtensionIfSupported(info.Filename)!)
.ToList();
foreach (var chapter in chapters)
{
chapter.IssueOrder = counter;
@ -483,6 +482,9 @@ public class ParseScannedFiles
return;
}
chapters = infos
.OrderByNatural(info => info.Chapters)
.ToList();
counter = 0f;
var prevIssue = string.Empty;

View file

@ -6,6 +6,7 @@ using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Metadata;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
@ -371,12 +372,26 @@ public class ProcessSeries : IProcessSeries
if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections)
{
// Get the default admin to associate these tags to
var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections);
if (defaultAdmin == null) return;
_logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name);
foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var t = await _tagManagerService.GetCollectionTag(collection);
if (t == null) continue;
_collectionTagService.AddTagToSeriesMetadata(t, series.Metadata);
var t = await _tagManagerService.GetCollectionTag(collection, defaultAdmin);
if (t.Item1 == null) continue;
var tag = t.Item1;
// Check if the Series is already on the tag
if (tag.Items.Any(s => s.MatchesSeriesByName(series.NormalizedName, series.NormalizedLocalizedName)))
{
continue;
}
tag.Items.Add(series);
await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(tag);
}
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -28,7 +29,7 @@ public interface ITagManagerService
Task<Genre?> GetGenre(string genre);
Task<Tag?> GetTag(string tag);
Task<Person?> GetPerson(string name, PersonRole role);
Task<CollectionTag?> GetCollectionTag(string name);
Task<Tuple<AppUserCollection?, bool>> GetCollectionTag(string? tag, AppUser userWithCollections);
}
/// <summary>
@ -41,7 +42,7 @@ public class TagManagerService : ITagManagerService
private Dictionary<string, Genre> _genres;
private Dictionary<string, Tag> _tags;
private Dictionary<string, Person> _people;
private Dictionary<string, CollectionTag> _collectionTags;
private Dictionary<string, AppUserCollection> _collectionTags;
private readonly SemaphoreSlim _genreSemaphore = new SemaphoreSlim(1, 1);
private readonly SemaphoreSlim _tagSemaphore = new SemaphoreSlim(1, 1);
@ -57,10 +58,10 @@ public class TagManagerService : ITagManagerService
public void Reset()
{
_genres = new Dictionary<string, Genre>();
_tags = new Dictionary<string, Tag>();
_people = new Dictionary<string, Person>();
_collectionTags = new Dictionary<string, CollectionTag>();
_genres = [];
_tags = [];
_people = [];
_collectionTags = [];
}
public async Task Prime()
@ -71,7 +72,8 @@ public class TagManagerService : ITagManagerService
.GroupBy(GetPersonKey)
.Select(g => g.First())
.ToDictionary(GetPersonKey);
_collectionTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync(CollectionTagIncludes.SeriesMetadata))
var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser()!;
_collectionTags = (await _unitOfWork.CollectionTagRepository.GetCollectionsForUserAsync(defaultAdmin.Id, CollectionIncludes.Series))
.ToDictionary(t => t.NormalizedTitle);
}
@ -183,28 +185,30 @@ public class TagManagerService : ITagManagerService
/// </summary>
/// <param name="tag"></param>
/// <returns></returns>
public async Task<CollectionTag?> GetCollectionTag(string tag)
public async Task<Tuple<AppUserCollection?, bool>> GetCollectionTag(string? tag, AppUser userWithCollections)
{
if (string.IsNullOrEmpty(tag)) return null;
if (string.IsNullOrEmpty(tag)) return Tuple.Create<AppUserCollection?, bool>(null, false);
await _collectionTagSemaphore.WaitAsync();
AppUserCollection? result;
try
{
if (_collectionTags.TryGetValue(tag.ToNormalized(), out var result))
if (_collectionTags.TryGetValue(tag.ToNormalized(), out result))
{
return result;
return Tuple.Create<AppUserCollection?, bool>(result, false);
}
// We need to create a new Genre
result = new CollectionTagBuilder(tag).Build();
_unitOfWork.CollectionTagRepository.Add(result);
result = new AppUserCollectionBuilder(tag).Build();
userWithCollections.Collections.Add(result);
_unitOfWork.UserRepository.Update(userWithCollections);
await _unitOfWork.CommitAsync();
_collectionTags.Add(result.NormalizedTitle, result);
return result;
}
finally
{
_collectionTagSemaphore.Release();
}
return Tuple.Create<AppUserCollection?, bool>(result, true);
}
}

View file

@ -134,7 +134,7 @@ public class StatsService : IStatsService
HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(),
NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(),
NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(),
NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count(),
NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(),
OPDSEnabled = serverSettings.EnableOpds,
NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(),