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

@ -40,8 +40,14 @@ public static class PolicyConstants
/// </summary>
/// <remarks>This is used explicitly for Demo Server. Not sure why it would be used in another fashion</remarks>
public const string ReadOnlyRole = "Read Only";
/// <summary>
/// Ability to promote entities (Collections, Reading Lists, etc).
/// </summary>
public const string PromoteRole = "Promote";
public static readonly ImmutableArray<string> ValidRoles =
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole);
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole, PromoteRole);
}

View file

@ -1,16 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.Entities.Metadata;
using API.Entities;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
@ -38,50 +40,37 @@ public class CollectionController : BaseApiController
}
/// <summary>
/// Return a list of all collection tags on the server for the logged in user.
/// Returns all Collection tags for a given User
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<ActionResult<IEnumerable<CollectionTagDto>>> GetAllTags()
public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetAllTags(bool ownedOnly = false)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
if (isAdmin)
{
return Ok(await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync());
}
return Ok(await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(user.Id));
return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), !ownedOnly));
}
/// <summary>
/// Searches against the collection tags on the DB and returns matches that meet the search criteria.
/// <remarks>Search strings will be cleaned of certain fields, like %</remarks>
/// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned)
/// </summary>
/// <param name="queryString">Search term</param>
/// <param name="seriesId"></param>
/// <param name="ownedOnly"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("search")]
public async Task<ActionResult<IEnumerable<CollectionTagDto>>> SearchTags(string? queryString)
[HttpGet("all-series")]
public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetCollectionsBySeries(int seriesId, bool ownedOnly = false)
{
queryString ??= string.Empty;
queryString = queryString.Replace(@"%", string.Empty);
if (queryString.Length == 0) return await GetAllTags();
return Ok(await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId()));
return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosBySeriesAsync(User.GetUserId(), seriesId, !ownedOnly));
}
/// <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>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("name-exists")]
public async Task<ActionResult<bool>> DoesNameExists(string name)
{
return Ok(await _collectionService.TagExistsByName(name));
return Ok(await _unitOfWork.CollectionTagRepository.CollectionExists(name, User.GetUserId()));
}
/// <summary>
@ -90,13 +79,15 @@ public class CollectionController : BaseApiController
/// </summary>
/// <param name="updatedTag"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateTag(CollectionTagDto updatedTag)
public async Task<ActionResult> UpdateTag(AppUserCollectionDto updatedTag)
{
try
{
if (await _collectionService.UpdateTag(updatedTag)) return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
if (await _collectionService.UpdateTag(updatedTag, User.GetUserId()))
{
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
}
}
catch (KavitaException ex)
{
@ -107,18 +98,94 @@ public class CollectionController : BaseApiController
}
/// <summary>
/// Adds a collection tag onto multiple Series. If tag id is 0, this will create a new tag.
/// Promote/UnPromote multiple collections in one go. Will only update the authenticated user's collections and will only work if the user has promotion role
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("promote-multiple")]
public async Task<ActionResult> PromoteMultipleCollections(PromoteCollectionsDto dto)
{
// This needs to take into account owner as I can select other users cards
var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds);
var userId = User.GetUserId();
if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole))
{
return BadRequest(await _localizationService.Translate(userId, "permission-denied"));
}
foreach (var collection in collections)
{
if (collection.AppUserId != userId) continue;
collection.Promoted = dto.Promoted;
_unitOfWork.CollectionTagRepository.Update(collection);
}
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Promote/UnPromote multiple collections in one go
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete-multiple")]
public async Task<ActionResult> DeleteMultipleCollections(PromoteCollectionsDto dto)
{
// This needs to take into account owner as I can select other users cards
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
user.Collections = user.Collections.Where(uc => !dto.CollectionIds.Contains(uc.Id)).ToList();
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Adds multiple series to a collection. If tag id is 0, this will create a new tag.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-for-series")]
public async Task<ActionResult> AddToMultipleSeries(CollectionTagBulkAddDto dto)
{
// Create a new tag and save
var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle);
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok();
AppUserCollection? tag;
if (dto.CollectionTagId == 0)
{
tag = new AppUserCollectionBuilder(dto.CollectionTagTitle).Build();
user.Collections.Add(tag);
}
else
{
// Validate tag doesn't exist
tag = user.Collections.FirstOrDefault(t => t.Id == dto.CollectionTagId);
}
if (tag == null)
{
return BadRequest(_localizationService.Translate(User.GetUserId(), "collection-doesnt-exists"));
}
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList());
foreach (var s in series)
{
if (tag.Items.Contains(s)) continue;
tag.Items.Add(s);
}
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync()) return Ok();
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
@ -128,13 +195,12 @@ public class CollectionController : BaseApiController
/// </summary>
/// <param name="updateSeriesForTagDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")]
public async Task<ActionResult> RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto)
{
try
{
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata);
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(updateSeriesForTagDto.Tag.Id, CollectionIncludes.Series);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
@ -149,24 +215,28 @@ public class CollectionController : BaseApiController
}
/// <summary>
/// Removes the collection tag from all Series it was attached to
/// Removes the collection tag from the user
/// </summary>
/// <param name="tagId"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete]
public async Task<ActionResult> DeleteTag(int tagId)
{
try
{
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
if (user.Collections.All(c => c.Id != tagId))
return BadRequest(await _localizationService.Translate(user.Id, "access-denied"));
if (await _collectionService.DeleteTag(tag))
if (await _collectionService.DeleteTag(tagId, user))
{
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted"));
}
}
catch (Exception)
catch (Exception ex)
{
await _unitOfWork.RollbackAsync();
}

View file

@ -111,7 +111,7 @@ public class ImageController : BaseApiController
}
/// <summary>
/// Returns cover image for Collection Tag
/// Returns cover image for Collection
/// </summary>
/// <param name="collectionTagId"></param>
/// <returns></returns>
@ -121,6 +121,7 @@ public class ImageController : BaseApiController
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
{

View file

@ -9,6 +9,7 @@ using API.Comparators;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
@ -450,15 +451,13 @@ public class OpdsController : BaseApiController
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync())
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId));
var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true);
var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
SetFeedId(feed, "collections");
@ -467,12 +466,15 @@ public class OpdsController : BaseApiController
Id = tag.Id.ToString(),
Title = tag.Title,
Summary = tag.Summary,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
}
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
]
}));
return CreateXmlResult(SerializeXml(feed));
@ -489,20 +491,9 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
IEnumerable <CollectionTagDto> tags;
if (isAdmin)
{
tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
}
else
{
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId);
}
var tag = tags.SingleOrDefault(t => t.Id == collectionId);
if (tag == null)
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId);
if (tag == null || (tag.AppUserId != user.Id && !tag.Promoted))
{
return BadRequest("Collection does not exist or you don't have access");
}

View file

@ -58,7 +58,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.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);

View file

@ -147,7 +147,7 @@ public class UploadController : BaseApiController
try
{
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id);
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadFileDto.Id);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");

View file

@ -40,6 +40,7 @@ public class WantToReadController : BaseApiController
/// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)
/// </summary>
/// <remarks>This will be removed in v0.8.x</remarks>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>

View file

@ -0,0 +1,39 @@
using System;
using API.Entities.Enums;
using API.Services.Plus;
namespace API.DTOs.Collection;
#nullable enable
public class AppUserCollectionDto
{
public int Id { get; init; }
public string Title { get; set; } = default!;
public string Summary { get; set; } = default!;
public bool Promoted { get; set; }
public AgeRating AgeRating { get; set; }
/// <summary>
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
/// </summary>
public string? CoverImage { get; set; } = string.Empty;
public bool CoverImageLocked { get; set; }
/// <summary>
/// Owner of the Collection
/// </summary>
public string? Owner { get; set; }
/// <summary>
/// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
/// </summary>
public DateTime LastSyncUtc { get; set; }
/// <summary>
/// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote
/// </summary>
public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita;
/// <summary>
/// For Non-Kavita sourced collections, the url to sync from
/// </summary>
public string? SourceUrl { get; set; }
}

View file

@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace API.DTOs.Collection;
public class DeleteCollectionsDto
{
public IList<int> CollectionIds { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.Collection;
public class PromoteCollectionsDto
{
public IList<int> CollectionIds { get; init; }
public bool Promoted { get; init; }
}

View file

@ -1,6 +1,7 @@
using System;
namespace API.DTOs.ReadingLists;
#nullable enable
public class ReadingListDto
{
@ -15,7 +16,7 @@ public class ReadingListDto
/// <summary>
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
/// </summary>
public string CoverImage { get; set; } = string.Empty;
public string? CoverImage { get; set; } = string.Empty;
/// <summary>
/// Minimum Year the Reading List starts
/// </summary>

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.DTOs.Reader;
@ -13,7 +14,7 @@ public class SearchResultGroupDto
{
public IEnumerable<LibraryDto> Libraries { get; set; } = default!;
public IEnumerable<SearchResultDto> Series { get; set; } = default!;
public IEnumerable<CollectionTagDto> Collections { get; set; } = default!;
public IEnumerable<AppUserCollectionDto> Collections { get; set; } = default!;
public IEnumerable<ReadingListDto> ReadingLists { get; set; } = default!;
public IEnumerable<PersonDto> Persons { get; set; } = default!;
public IEnumerable<GenreTagDto> Genres { get; set; } = default!;

View file

@ -1,5 +1,4 @@
using System.Collections.Generic;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.Entities.Enums;
@ -10,11 +9,6 @@ public class SeriesMetadataDto
public int Id { get; set; }
public string Summary { get; set; } = string.Empty;
/// <summary>
/// Collections the Series belongs to
/// </summary>
public ICollection<CollectionTagDto> CollectionTags { get; set; } = new List<CollectionTagDto>();
/// <summary>
/// Genres for the Series
/// </summary>

View file

@ -1,11 +1,6 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.DTOs.CollectionTags;
namespace API.DTOs;
namespace API.DTOs;
public class UpdateSeriesMetadataDto
{
public SeriesMetadataDto SeriesMetadata { get; set; } = default!;
public ICollection<CollectionTagDto> CollectionTags { get; set; } = default!;
}

View file

@ -36,6 +36,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ServerSetting> ServerSetting { get; set; } = null!;
public DbSet<AppUserPreferences> AppUserPreferences { get; set; } = null!;
public DbSet<SeriesMetadata> SeriesMetadata { get; set; } = null!;
[Obsolete]
public DbSet<CollectionTag> CollectionTag { get; set; } = null!;
public DbSet<AppUserBookmark> AppUserBookmark { get; set; } = null!;
public DbSet<ReadingList> ReadingList { get; set; } = null!;
@ -64,6 +65,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!;
public DbSet<AppUserCollection> AppUserCollection { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
@ -149,6 +151,10 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.WithOne(s => s.ExternalSeriesMetadata)
.HasForeignKey<ExternalSeriesMetadata>(em => em.SeriesId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<AppUserCollection>()
.Property(b => b.AgeRating)
.HasDefaultValue(AgeRating.Unknown);
}
#nullable enable

View file

@ -0,0 +1,80 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Extensions.QueryExtensions;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.8.0 refactored User Collections
/// </summary>
public static class MigrateCollectionTagToUserCollections
{
public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger<Program> logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateCollectionTagToUserCollections"))
{
return;
}
logger.LogCritical(
"Running MigrateCollectionTagToUserCollections migration - Please be patient, this may take some time. This is not an error");
// Find the first user that is an admin
var defaultAdmin = await unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections);
if (defaultAdmin == null)
{
await CompleteMigration(dataContext, logger);
return;
}
// For all collectionTags, move them over to said user
var existingCollections = await dataContext.CollectionTag
.OrderBy(c => c.NormalizedTitle)
.Includes(CollectionTagIncludes.SeriesMetadataWithSeries)
.ToListAsync();
foreach (var existingCollectionTag in existingCollections)
{
var collection = new AppUserCollection()
{
Title = existingCollectionTag.Title,
NormalizedTitle = existingCollectionTag.Title.Normalize(),
CoverImage = existingCollectionTag.CoverImage,
CoverImageLocked = existingCollectionTag.CoverImageLocked,
Promoted = existingCollectionTag.Promoted,
AgeRating = AgeRating.Unknown,
Summary = existingCollectionTag.Summary,
Items = existingCollectionTag.SeriesMetadatas.Select(s => s.Series).ToList()
};
collection.AgeRating = await unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(collection.Items.Select(s => s.Id));
defaultAdmin.Collections.Add(collection);
}
unitOfWork.UserRepository.Update(defaultAdmin);
await unitOfWork.CommitAsync();
await CompleteMigration(dataContext, logger);
}
private static async Task CompleteMigration(DataContext dataContext, ILogger<Program> logger)
{
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateCollectionTagToUserCollections",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await dataContext.SaveChangesAsync();
logger.LogCritical(
"Running MigrateCollectionTagToUserCollections migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,92 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class UserBasedCollections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppUserCollection",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", nullable: true),
NormalizedTitle = table.Column<string>(type: "TEXT", nullable: true),
Summary = table.Column<string>(type: "TEXT", nullable: true),
Promoted = table.Column<bool>(type: "INTEGER", nullable: false),
CoverImage = table.Column<string>(type: "TEXT", nullable: true),
CoverImageLocked = table.Column<bool>(type: "INTEGER", nullable: false),
AgeRating = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastSyncUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
Source = table.Column<int>(type: "INTEGER", nullable: false),
SourceUrl = table.Column<string>(type: "TEXT", nullable: true),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserCollection", x => x.Id);
table.ForeignKey(
name: "FK_AppUserCollection_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AppUserCollectionSeries",
columns: table => new
{
CollectionsId = table.Column<int>(type: "INTEGER", nullable: false),
ItemsId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserCollectionSeries", x => new { x.CollectionsId, x.ItemsId });
table.ForeignKey(
name: "FK_AppUserCollectionSeries_AppUserCollection_CollectionsId",
column: x => x.CollectionsId,
principalTable: "AppUserCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppUserCollectionSeries_Series_ItemsId",
column: x => x.ItemsId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserCollection_AppUserId",
table: "AppUserCollection",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserCollectionSeries_ItemsId",
table: "AppUserCollectionSeries",
column: "ItemsId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserCollectionSeries");
migrationBuilder.DropTable(
name: "AppUserCollection");
}
}
}

View file

@ -189,6 +189,66 @@ namespace API.Data.Migrations
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AgeRating")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncUtc")
.HasColumnType("TEXT");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.Property<string>("SourceUrl")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserCollection");
});
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
{
b.Property<int>("Id")
@ -1918,6 +1978,21 @@ namespace API.Data.Migrations
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserCollectionSeries", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("INTEGER");
b.Property<int>("ItemsId")
.HasColumnType("INTEGER");
b.HasKey("CollectionsId", "ItemsId");
b.HasIndex("ItemsId");
b.ToTable("AppUserCollectionSeries");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
@ -2178,6 +2253,17 @@ namespace API.Data.Migrations
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Collections")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -2626,6 +2712,21 @@ namespace API.Data.Migrations
b.Navigation("Series");
});
modelBuilder.Entity("AppUserCollectionSeries", b =>
{
b.HasOne("API.Entities.AppUserCollection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", null)
.WithMany()
.HasForeignKey("ItemsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.HasOne("API.Entities.AppUser", null)
@ -2836,6 +2937,8 @@ namespace API.Data.Migrations
{
b.Navigation("Bookmarks");
b.Navigation("Collections");
b.Navigation("DashboardStreams");
b.Navigation("Devices");

View file

@ -3,43 +3,61 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data.Misc;
using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
#nullable enable
[Flags]
public enum CollectionTagIncludes
{
None = 1,
SeriesMetadata = 2,
SeriesMetadataWithSeries = 4
}
[Flags]
public enum CollectionIncludes
{
None = 1,
Series = 2,
}
public interface ICollectionTagRepository
{
void Add(CollectionTag tag);
void Remove(CollectionTag tag);
Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync();
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId);
void Remove(AppUserCollection tag);
Task<string?> GetCoverImageAsync(int collectionTagId);
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync(int userId);
Task<CollectionTag?> GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None);
void Update(CollectionTag tag);
Task<int> RemoveTagsWithoutSeries();
Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None);
Task<AppUserCollection?> GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None);
void Update(AppUserCollection tag);
Task<int> RemoveCollectionsWithoutSeries();
Task<IEnumerable<AppUserCollection>> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None);
/// <summary>
/// Returns all of the user's collections with the option of other user's promoted
/// </summary>
/// <param name="userId"></param>
/// <param name="includePromoted"></param>
/// <returns></returns>
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosAsync(int userId, bool includePromoted = false);
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false);
Task<IEnumerable<CollectionTag>> GetAllTagsByNamesAsync(IEnumerable<string> normalizedTitles,
CollectionTagIncludes includes = CollectionTagIncludes.None);
Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> TagExists(string title);
Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<bool> CollectionExists(string title, int userId);
Task<IList<AppUserCollection>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<IList<string>> GetRandomCoverImagesAsync(int collectionId);
Task<IList<AppUserCollection>> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None);
Task UpdateCollectionAgeRating(AppUserCollection tag);
Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None);
}
public class CollectionTagRepository : ICollectionTagRepository
{
@ -52,17 +70,12 @@ public class CollectionTagRepository : ICollectionTagRepository
_mapper = mapper;
}
public void Add(CollectionTag tag)
public void Remove(AppUserCollection tag)
{
_context.CollectionTag.Add(tag);
_context.AppUserCollection.Remove(tag);
}
public void Remove(CollectionTag tag)
{
_context.CollectionTag.Remove(tag);
}
public void Update(CollectionTag tag)
public void Update(AppUserCollection tag)
{
_context.Entry(tag).State = EntityState.Modified;
}
@ -70,38 +83,53 @@ public class CollectionTagRepository : ICollectionTagRepository
/// <summary>
/// Removes any collection tags without any series
/// </summary>
public async Task<int> RemoveTagsWithoutSeries()
public async Task<int> RemoveCollectionsWithoutSeries()
{
var tagsToDelete = await _context.CollectionTag
.Include(c => c.SeriesMetadatas)
.Where(c => c.SeriesMetadatas.Count == 0)
var tagsToDelete = await _context.AppUserCollection
.Include(c => c.Items)
.Where(c => c.Items.Count == 0)
.AsSplitQuery()
.ToListAsync();
_context.RemoveRange(tagsToDelete);
return await _context.SaveChangesAsync();
}
public async Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None)
public async Task<IEnumerable<AppUserCollection>> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None)
{
return await _context.CollectionTag
return await _context.AppUserCollection
.OrderBy(c => c.NormalizedTitle)
.Includes(includes)
.ToListAsync();
}
public async Task<IEnumerable<CollectionTag>> GetAllTagsByNamesAsync(IEnumerable<string> normalizedTitles, CollectionTagIncludes includes = CollectionTagIncludes.None)
public async Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosAsync(int userId, bool includePromoted = false)
{
return await _context.CollectionTag
.Where(c => normalizedTitles.Contains(c.NormalizedTitle))
.OrderBy(c => c.NormalizedTitle)
.Includes(includes)
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.AppUserCollection
.Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted))
.WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating)
.OrderBy(uc => uc.Title)
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.AppUserCollection
.Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted))
.Where(uc => uc.Items.Any(s => s.Id == seriesId))
.WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating)
.OrderBy(uc => uc.Title)
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<string?> GetCoverImageAsync(int collectionTagId)
{
return await _context.CollectionTag
return await _context.AppUserCollection
.Where(c => c.Id == collectionTagId)
.Select(c => c.CoverImage)
.SingleOrDefaultAsync();
@ -109,12 +137,13 @@ public class CollectionTagRepository : ICollectionTagRepository
public async Task<IList<string>> GetAllCoverImagesAsync()
{
return (await _context.CollectionTag
return await _context.AppUserCollection
.Select(t => t.CoverImage)
.Where(t => !string.IsNullOrEmpty(t))
.ToListAsync())!;
.ToListAsync();
}
[Obsolete("use TagExists with userId")]
public async Task<bool> TagExists(string title)
{
var normalized = title.ToNormalized();
@ -122,10 +151,24 @@ public class CollectionTagRepository : ICollectionTagRepository
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
}
public async Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
/// <summary>
/// If any tag exists for that given user's collections
/// </summary>
/// <param name="title"></param>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<bool> CollectionExists(string title, int userId)
{
var normalized = title.ToNormalized();
return await _context.AppUserCollection
.Where(uc => uc.AppUserId == userId)
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
}
public async Task<IList<AppUserCollection>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{
var extension = encodeFormat.GetExtension();
return await _context.CollectionTag
return await _context.AppUserCollection
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync();
}
@ -139,12 +182,41 @@ public class CollectionTagRepository : ICollectionTagRepository
.Select(sm => sm.Series.CoverImage)
.Where(t => !string.IsNullOrEmpty(t))
.ToListAsync();
return data
.OrderBy(_ => random.Next())
.Take(4)
.ToList();
}
public async Task<IList<AppUserCollection>> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None)
{
return await _context.AppUserCollection
.Where(c => c.AppUserId == userId)
.Includes(includes)
.ToListAsync();
}
public async Task UpdateCollectionAgeRating(AppUserCollection tag)
{
var maxAgeRating = await _context.AppUserCollection
.Where(t => t.Id == tag.Id)
.SelectMany(uc => uc.Items.Select(s => s.Metadata))
.Select(sm => sm.AgeRating)
.MaxAsync();
tag.AgeRating = maxAgeRating;
await _context.SaveChangesAsync();
}
public async Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None)
{
return await _context.AppUserCollection
.Where(c => tags.Contains(c.Id))
.Includes(includes)
.AsSplitQuery()
.ToListAsync();
}
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
{
@ -168,9 +240,9 @@ public class CollectionTagRepository : ICollectionTagRepository
}
public async Task<CollectionTag?> GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None)
public async Task<AppUserCollection?> GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None)
{
return await _context.CollectionTag
return await _context.AppUserCollection
.Where(c => c.Id == tagId)
.Includes(includes)
.AsSplitQuery()
@ -190,16 +262,12 @@ public class CollectionTagRepository : ICollectionTagRepository
.SingleAsync();
}
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId)
public async Task<IEnumerable<AppUserCollectionDto>> SearchTagDtosAsync(string searchQuery, int userId)
{
var userRating = await GetUserAgeRestriction(userId);
return await _context.CollectionTag
.Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%")
|| EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle)
.AsNoTracking()
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
return await _context.AppUserCollection
.Search(searchQuery, userId, userRating)
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
}

View file

@ -7,6 +7,7 @@ using API.Constants;
using API.Data.Misc;
using API.Data.Scanner;
using API.DTOs;
using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.DTOs.Dashboard;
using API.DTOs.Filtering;
@ -141,7 +142,7 @@ public interface ISeriesRepository
MangaFormat format);
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
/// <summary>
/// This is only used for <see cref="MigrateUserProgressLibraryId"/>
/// </summary>
@ -342,10 +343,7 @@ public class SeriesRepository : ISeriesRepository
return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync();
}
return new List<int>()
{
libraryId
};
return [libraryId];
}
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery)
@ -362,12 +360,9 @@ public class SeriesRepository : ISeriesRepository
.ToList();
result.Libraries = await _context.Library
.Where(l => libraryIds.Contains(l.Id))
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.IsRestricted(QueryContext.Search)
.AsSplitQuery()
.OrderBy(l => l.Name.ToLower())
.Search(searchQuery, userId, libraryIds)
.Take(maxRecords)
.OrderBy(l => l.Name.ToLower())
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -419,53 +414,33 @@ public class SeriesRepository : ISeriesRepository
result.ReadingLists = await _context.ReadingList
.Where(rl => rl.AppUserId == userId || rl.Promoted)
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.AsSplitQuery()
.OrderBy(r => r.NormalizedTitle)
.Search(searchQuery, userId, userRating)
.Take(maxRecords)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Collections = await _context.CollectionTag
.Where(c => (EF.Functions.Like(c.Title, $"%{searchQuery}%"))
|| (EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%")))
.Where(c => c.Promoted || isAdmin)
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle)
.AsSplitQuery()
result.Collections = await _context.AppUserCollection
.Search(searchQuery, userId, userRating)
.Take(maxRecords)
.OrderBy(c => c.NormalizedTitle)
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Persons = await _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(p => p.NormalizedName)
.SearchPeople(searchQuery, seriesIds)
.Take(maxRecords)
.OrderBy(t => t.NormalizedName)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Genres = await _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(t => t.NormalizedTitle)
.SearchGenres(searchQuery, seriesIds)
.Take(maxRecords)
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Tags = await _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(t => t.NormalizedTitle)
.SearchTags(searchQuery, seriesIds)
.Take(maxRecords)
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -740,6 +715,7 @@ public class SeriesRepository : ISeriesRepository
.FirstOrDefaultAsync();
}
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
{
var userProgress = await _context.AppUserProgresses
@ -968,6 +944,20 @@ public class SeriesRepository : ISeriesRepository
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter,
out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter);
IList<int> collectionSeries = [];
if (hasCollectionTagFilter)
{
collectionSeries = await _context.AppUserCollection
.Where(uc => uc.Promoted || uc.AppUserId == userId)
.Where(uc => filter.CollectionTags.Contains(uc.Id))
.SelectMany(uc => uc.Items)
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.Distinct()
.ToListAsync();
}
var query = _context.Series
.AsNoTracking()
// This new style can handle any filterComparision coming from the user
@ -979,7 +969,7 @@ public class SeriesRepository : ISeriesRepository
.HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
.HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
.HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
.HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags)
.HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags, collectionSeries)
.HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
@ -1045,6 +1035,8 @@ public class SeriesRepository : ISeriesRepository
.Select(u => u.CollapseSeriesRelationships)
.SingleOrDefaultAsync();
query ??= _context.Series
.AsNoTracking();
@ -1062,6 +1054,9 @@ public class SeriesRepository : ISeriesRepository
query = ApplyWantToReadFilter(filter, query, userId);
query = await ApplyCollectionFilter(filter, query, userId, userRating);
query = BuildFilterQuery(userId, filter, query);
@ -1078,6 +1073,50 @@ public class SeriesRepository : ISeriesRepository
.AsSplitQuery(), filter.LimitTo);
}
private async Task<IQueryable<Series>> ApplyCollectionFilter(FilterV2Dto filter, IQueryable<Series> query, int userId, AgeRestriction userRating)
{
var collectionStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.CollectionTags);
if (collectionStmt == null) return query;
var value = (IList<int>) FilterFieldValueConverter.ConvertValue(collectionStmt.Field, collectionStmt.Value);
if (value.Count == 0)
{
return query;
}
var collectionSeries = await _context.AppUserCollection
.Where(uc => uc.Promoted || uc.AppUserId == userId)
.Where(uc => value.Contains(uc.Id))
.SelectMany(uc => uc.Items)
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.Distinct()
.ToListAsync();
if (collectionStmt.Comparison != FilterComparison.MustContains)
return query.HasCollectionTags(true, collectionStmt.Comparison, value, collectionSeries);
var collectionSeriesTasks = value.Select(async collectionId =>
{
return await _context.AppUserCollection
.Where(uc => uc.Promoted || uc.AppUserId == userId)
.Where(uc => uc.Id == collectionId)
.SelectMany(uc => uc.Items)
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.ToListAsync();
});
var collectionSeriesLists = await Task.WhenAll(collectionSeriesTasks);
// Find the common series among all collections
var commonSeries = collectionSeriesLists.Aggregate((common, next) => common.Intersect(next).ToList());
// Filter the original query based on the common series
return query.Where(s => commonSeries.Contains(s.Id));
}
private IQueryable<Series> ApplyWantToReadFilter(FilterV2Dto filter, IQueryable<Series> query, int userId)
{
var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
@ -1175,7 +1214,6 @@ public class SeriesRepository : ISeriesRepository
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList<int>) value),
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
@ -1190,6 +1228,9 @@ public class SeriesRepository : ISeriesRepository
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
FilterField.CollectionTags =>
// This is handled in the code before this as it's handled in a more general, combined manner
query,
FilterField.Libraries =>
// This is handled in the code before this as it's handled in a more general, combined manner
query,
@ -1241,7 +1282,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
{
var metadataDto = await _context.SeriesMetadata
return await _context.SeriesMetadata
.Where(metadata => metadata.SeriesId == seriesId)
.Include(m => m.Genres.OrderBy(g => g.NormalizedTitle))
.Include(m => m.Tags.OrderBy(g => g.NormalizedTitle))
@ -1250,42 +1291,20 @@ public class SeriesRepository : ISeriesRepository
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.SingleOrDefaultAsync();
if (metadataDto != null)
{
metadataDto.CollectionTags = await _context.CollectionTag
.Include(t => t.SeriesMetadatas)
.Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId))
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.OrderBy(t => t.Title.ToLower())
.AsSplitQuery()
.ToListAsync();
}
return metadataDto;
}
public async Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams)
{
var userLibraries = _context.Library
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(user => user.Id == userId))
.AsSplitQuery()
.AsNoTracking()
.Select(library => library.Id)
.ToList();
var userLibraries = _context.Library.GetUserLibraries(userId);
var query = _context.CollectionTag
var query = _context.AppUserCollection
.Where(s => s.Id == collectionId)
.Include(c => c.SeriesMetadatas)
.ThenInclude(m => m.Series)
.SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId)))
.Include(c => c.Items)
.SelectMany(c => c.Items.Where(s => userLibraries.Contains(s.LibraryId)))
.OrderBy(s => s.LibraryId)
.ThenBy(s => s.SortName.ToLower())
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
.AsSplitQuery();
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
@ -2072,18 +2091,20 @@ public class SeriesRepository : ISeriesRepository
}
/// <summary>
/// Returns the highest Age Rating for a list of Series
/// Returns the highest Age Rating for a list of Series. Defaults to <see cref="AgeRating.Unknown"/>
/// </summary>
/// <param name="seriesIds"></param>
/// <returns></returns>
public async Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds)
public async Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds)
{
return await _context.Series
var ret = await _context.Series
.Where(s => seriesIds.Contains(s.Id))
.Include(s => s.Metadata)
.Select(s => s.Metadata.AgeRating)
.OrderBy(s => s)
.LastOrDefaultAsync();
if (ret == null) return AgeRating.Unknown;
return ret;
}
/// <summary>

View file

@ -38,7 +38,8 @@ public enum AppUserIncludes
SmartFilters = 1024,
DashboardStreams = 2048,
SideNavStreams = 4096,
ExternalSources = 8192 // 2^13
ExternalSources = 8192,
Collections = 16384 // 2^14
}
public interface IUserRepository
@ -57,6 +58,7 @@ public interface IUserRepository
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<bool> IsUserAdminAsync(AppUser? user);
Task<IList<string>> GetRoles(int userId);
Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId);
Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId);
Task<AppUserPreferences?> GetPreferencesAsync(string username);
@ -78,7 +80,7 @@ public interface IUserRepository
Task<bool> HasAccessToSeries(int userId, int seriesId);
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None);
Task<AppUser?> GetUserByConfirmationToken(string token);
Task<AppUser> GetDefaultAdminUser();
Task<AppUser> GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None);
Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId);
Task<IEnumerable<AppUserRating>> GetSeriesWithReviews(int userId);
Task<bool> HasHoldOnSeries(int userId, int seriesId);
@ -298,11 +300,13 @@ public class UserRepository : IUserRepository
/// Returns the first admin account created
/// </summary>
/// <returns></returns>
public async Task<AppUser> GetDefaultAdminUser()
public async Task<AppUser> GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None)
{
return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole))
return await _context.AppUser
.Includes(includes)
.Where(u => u.UserRoles.Any(r => r.Role.Name == PolicyConstants.AdminRole))
.OrderBy(u => u.Created)
.First();
.FirstAsync();
}
public async Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId)
@ -482,7 +486,7 @@ public class UserRepository : IUserRepository
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole)).OrderBy(u => u.CreatedUtc);
}
public async Task<bool> IsUserAdminAsync(AppUser? user)
@ -491,6 +495,14 @@ public class UserRepository : IUserRepository
return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
}
public async Task<IList<string>> GetRoles(int userId)
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null || _userManager == null) return ArraySegment<string>.Empty; // userManager is null on Unit Tests only
return await _userManager.GetRolesAsync(user);
}
public async Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId)
{
return await _context.AppUserRating

View file

@ -29,6 +29,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// </summary>
public ICollection<ReadingList> ReadingLists { get; set; } = null!;
/// <summary>
/// Collections associated with this user
/// </summary>
public ICollection<AppUserCollection> Collections { get; set; } = null!;
/// <summary>
/// A list of Series the user want's to read
/// </summary>
public ICollection<AppUserWantToRead> WantToRead { get; set; } = null!;

View file

@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Services.Plus;
namespace API.Entities;
/// <summary>
/// Represents a Collection of Series for a given User
/// </summary>
public class AppUserCollection : IEntityDate
{
public int Id { get; set; }
public required string Title { get; set; }
/// <summary>
/// A normalized string used to check if the collection already exists in the DB
/// </summary>
public required string NormalizedTitle { get; set; }
public string? Summary { get; set; }
/// <summary>
/// Reading lists that are promoted are only done by admins
/// </summary>
public bool Promoted { get; set; }
/// <summary>
/// Path to the (managed) image file
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string? CoverImage { get; set; }
public bool CoverImageLocked { get; set; }
/// <summary>
/// The highest age rating from all Series within the collection
/// </summary>
public required AgeRating AgeRating { get; set; } = AgeRating.Unknown;
public ICollection<Series> Items { get; set; } = null!;
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
// Sync stuff for Kavita+
/// <summary>
/// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
/// </summary>
public DateTime LastSyncUtc { get; set; }
/// <summary>
/// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote
/// </summary>
public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita;
/// <summary>
/// For Non-Kavita sourced collections, the url to sync from
/// </summary>
public string? SourceUrl { get; set; }
// Relationship
public AppUser AppUser { get; set; } = null!;
public int AppUserId { get; set; }
}

View file

@ -9,6 +9,7 @@ namespace API.Entities;
/// <summary>
/// Represents a user entered field that is used as a tagging and grouping mechanism
/// </summary>
[Obsolete("Use AppUserCollection instead")]
[Index(nameof(Id), nameof(Promoted), IsUnique = true)]
public class CollectionTag
{

View file

@ -14,6 +14,7 @@ public class SeriesMetadata : IHasConcurrencyToken
public string Summary { get; set; } = string.Empty;
[Obsolete("Use AppUserCollection instead")]
public ICollection<CollectionTag> CollectionTags { get; set; } = new List<CollectionTag>();
public ICollection<Genre> Genres { get; set; } = new List<Genre>();

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Entities.Metadata;
using API.Extensions;
namespace API.Entities;
@ -105,6 +106,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate
public ICollection<AppUserRating> Ratings { get; set; } = null!;
public ICollection<AppUserProgress> Progress { get; set; } = null!;
public ICollection<AppUserCollection> Collections { get; set; } = null!;
/// <summary>
/// Relations to other Series, like Sequels, Prequels, etc
@ -114,6 +116,8 @@ public class Series : IEntityDate, IHasReadTimeEstimate
public ICollection<SeriesRelation> RelationOf { get; set; } = null!;
// Relationships
public List<Volume> Volumes { get; set; } = null!;
public Library Library { get; set; } = null!;
@ -131,4 +135,12 @@ public class Series : IEntityDate, IHasReadTimeEstimate
LastChapterAdded = DateTime.Now;
LastChapterAddedUtc = DateTime.UtcNow;
}
public bool MatchesSeriesByName(string nameNormalized, string localizedNameNormalized)
{
return NormalizedName == nameNormalized ||
NormalizedLocalizedName == nameNormalized ||
NormalizedName == localizedNameNormalized ||
NormalizedLocalizedName == localizedNameNormalized;
}
}

View file

@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Linq;
using API.Data.Misc;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Metadata;
using Microsoft.EntityFrameworkCore;
namespace API.Extensions.QueryExtensions.Filtering;
public static class SearchQueryableExtensions
{
public static IQueryable<AppUserCollection> Search(this IQueryable<AppUserCollection> queryable,
string searchQuery, int userId, AgeRestriction userRating)
{
return queryable
.Where(uc => uc.Promoted || uc.AppUserId == userId)
.Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%")
|| EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle);
}
public static IQueryable<ReadingList> Search(this IQueryable<ReadingList> queryable,
string searchQuery, int userId, AgeRestriction userRating)
{
return queryable
.Where(rl => rl.AppUserId == userId || rl.Promoted)
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle);
}
public static IQueryable<Library> Search(this IQueryable<Library> queryable,
string searchQuery, int userId, IEnumerable<int> libraryIds)
{
return queryable
.Where(l => libraryIds.Contains(l.Id))
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.IsRestricted(QueryContext.Search)
.AsSplitQuery()
.OrderBy(l => l.Name.ToLower());
}
public static IQueryable<Person> SearchPeople(this IQueryable<SeriesMetadata> queryable,
string searchQuery, IEnumerable<int> seriesIds)
{
return queryable
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(p => p.NormalizedName);
}
public static IQueryable<Genre> SearchGenres(this IQueryable<SeriesMetadata> queryable,
string searchQuery, IEnumerable<int> seriesIds)
{
return queryable
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.Distinct()
.OrderBy(t => t.NormalizedTitle);
}
public static IQueryable<Tag> SearchTags(this IQueryable<SeriesMetadata> queryable,
string searchQuery, IEnumerable<int> seriesIds)
{
return queryable
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(t => t.NormalizedTitle);
}
}

View file

@ -551,25 +551,26 @@ public static class SeriesFilter
}
public static IQueryable<Series> HasCollectionTags(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> collectionTags)
FilterComparison comparison, IList<int> collectionTags, IList<int> collectionSeries)
{
if (!condition || collectionTags.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id)));
return queryable.Where(s => collectionSeries.Contains(s.Id));
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id)));
return queryable.Where(s => !collectionSeries.Contains(s.Id));
case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
// // Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>()
{
queryable
};
queries.AddRange(collectionTags.Select(gId => queryable.Where(s => s.Metadata.CollectionTags.Any(p => p.Id == gId))));
queries.AddRange(collectionSeries.Select(gId => queryable.Where(s => collectionSeries.Any(p => p == s.Id))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan:

View file

@ -19,6 +19,23 @@ public static class IncludesExtensions
queryable = queryable.Include(c => c.SeriesMetadatas);
}
if (includes.HasFlag(CollectionTagIncludes.SeriesMetadataWithSeries))
{
queryable = queryable.Include(c => c.SeriesMetadatas).ThenInclude(s => s.Series);
}
return queryable.AsSplitQuery();
}
public static IQueryable<AppUserCollection> Includes(this IQueryable<AppUserCollection> queryable,
CollectionIncludes includes)
{
if (includes.HasFlag(CollectionIncludes.Series))
{
queryable = queryable.Include(c => c.Items);
}
return queryable.AsSplitQuery();
}
@ -206,6 +223,12 @@ public static class IncludesExtensions
query = query.Include(u => u.ExternalSources);
}
if (includeFlags.HasFlag(AppUserIncludes.Collections))
{
query = query.Include(u => u.Collections)
.ThenInclude(c => c.Items);
}
return query.AsSplitQuery();
}

View file

@ -1,4 +1,5 @@
using System.Linq;
using System;
using System.Linq;
using API.Data.Misc;
using API.Entities;
using API.Entities.Enums;
@ -24,6 +25,7 @@ public static class RestrictByAgeExtensions
return q;
}
[Obsolete]
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRestriction restriction)
{
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
@ -38,6 +40,20 @@ public static class RestrictByAgeExtensions
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
}
public static IQueryable<AppUserCollection> RestrictAgainstAgeRestriction(this IQueryable<AppUserCollection> queryable, AgeRestriction restriction)
{
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
if (restriction.IncludeUnknowns)
{
return queryable.Where(c => c.Items.All(sm =>
sm.Metadata.AgeRating <= restriction.AgeRating));
}
return queryable.Where(c => c.Items.All(sm =>
sm.Metadata.AgeRating <= restriction.AgeRating && sm.Metadata.AgeRating > AgeRating.Unknown));
}
public static IQueryable<Genre> RestrictAgainstAgeRestriction(this IQueryable<Genre> queryable, AgeRestriction restriction)
{
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;

View file

@ -3,6 +3,7 @@ using System.Linq;
using API.Data.Migrations;
using API.DTOs;
using API.DTOs.Account;
using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.DTOs.Dashboard;
using API.DTOs.Device;
@ -53,6 +54,8 @@ public class AutoMapperProfiles : Profile
CreateMap<Chapter, ChapterDto>();
CreateMap<Series, SeriesDto>();
CreateMap<CollectionTag, CollectionTagDto>();
CreateMap<AppUserCollection, AppUserCollectionDto>()
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName));
CreateMap<Person, PersonDto>();
CreateMap<Genre, GenreTagDto>();
CreateMap<Tag, TagDto>();
@ -141,10 +144,6 @@ public class AutoMapperProfiles : Profile
opt =>
opt.MapFrom(
src => src.Genres.OrderBy(p => p.NormalizedTitle)))
.ForMember(dest => dest.CollectionTags,
opt =>
opt.MapFrom(
src => src.CollectionTags.OrderBy(p => p.NormalizedTitle)))
.ForMember(dest => dest.Tags,
opt =>
opt.MapFrom(

View file

@ -0,0 +1,72 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services.Plus;
namespace API.Helpers.Builders;
public class AppUserCollectionBuilder : IEntityBuilder<AppUserCollection>
{
private readonly AppUserCollection _collection;
public AppUserCollection Build() => _collection;
public AppUserCollectionBuilder(string title, bool promoted = false)
{
title = title.Trim();
_collection = new AppUserCollection()
{
Id = 0,
NormalizedTitle = title.ToNormalized(),
Title = title,
Promoted = promoted,
Summary = string.Empty,
AgeRating = AgeRating.Unknown,
Source = ScrobbleProvider.Kavita,
Items = new List<Series>()
};
}
public AppUserCollectionBuilder WithSource(ScrobbleProvider provider)
{
_collection.Source = provider;
return this;
}
public AppUserCollectionBuilder WithSummary(string summary)
{
_collection.Summary = summary;
return this;
}
public AppUserCollectionBuilder WithIsPromoted(bool promoted)
{
_collection.Promoted = promoted;
return this;
}
public AppUserCollectionBuilder WithItem(Series series)
{
_collection.Items ??= new List<Series>();
_collection.Items.Add(series);
return this;
}
public AppUserCollectionBuilder WithItems(IEnumerable<Series> series)
{
_collection.Items ??= new List<Series>();
foreach (var s in series)
{
_collection.Items.Add(s);
}
return this;
}
public AppUserCollectionBuilder WithCoverImage(string cover)
{
_collection.CoverImage = cover;
return this;
}
}

View file

@ -1,57 +0,0 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Metadata;
using API.Extensions;
namespace API.Helpers.Builders;
public class CollectionTagBuilder : IEntityBuilder<CollectionTag>
{
private readonly CollectionTag _collectionTag;
public CollectionTag Build() => _collectionTag;
public CollectionTagBuilder(string title, bool promoted = false)
{
title = title.Trim();
_collectionTag = new CollectionTag()
{
Id = 0,
NormalizedTitle = title.ToNormalized(),
Title = title,
Promoted = promoted,
Summary = string.Empty,
SeriesMetadatas = new List<SeriesMetadata>()
};
}
public CollectionTagBuilder WithId(int id)
{
_collectionTag.Id = id;
return this;
}
public CollectionTagBuilder WithSummary(string summary)
{
_collectionTag.Summary = summary;
return this;
}
public CollectionTagBuilder WithIsPromoted(bool promoted)
{
_collectionTag.Promoted = promoted;
return this;
}
public CollectionTagBuilder WithSeriesMetadata(SeriesMetadata seriesMetadata)
{
_collectionTag.SeriesMetadatas ??= new List<SeriesMetadata>();
_collectionTag.SeriesMetadatas.Add(seriesMetadata);
return this;
}
public CollectionTagBuilder WithCoverImage(string cover)
{
_collectionTag.CoverImage = cover;
return this;
}
}

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(),

View file

@ -261,6 +261,7 @@ public class Startup
await MigrateChapterFields.Migrate(dataContext, unitOfWork, logger);
await MigrateChapterRange.Migrate(dataContext, unitOfWork, logger);
await MigrateMangaFilePath.Migrate(dataContext, logger);
await MigrateCollectionTagToUserCollections.Migrate(dataContext, unitOfWork, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);