Misc Fixes + Enhancements (#1875)
* Moved Collapse Series with relationships into a user preference rather than library setting. * Fixed bookmarks not converting to webp after initial save * Fixed a bug where when merging we'd print out a duplicate series error when we shouldn't have * Fixed a bug where clicking on a genre or tag from server stats wouldn't load all-series page in a filtered state. * Implemented the ability to have Login role and thus disable accounts. * Ensure first time flow gets the Login role * Refactored user management screen so that pending users can be edited or deleted before the end user accepts the invite. A side effect is old legacy users that were here before email was required can now be deleted. * Show a progress bar under the main series image on larger viewports to show whole series progress. * Removed code no longer needed * Cleanup tags, people, collections without connections after editing series metadata. * Moved the Entity Builders to the main project
This commit is contained in:
parent
c62e594792
commit
bd19b282d5
63 changed files with 2186 additions and 239 deletions
|
|
@ -121,6 +121,12 @@
|
|||
<None Remove="kavita.db" />
|
||||
<None Remove="covers\**" />
|
||||
<None Remove="wwwroot\**" />
|
||||
<None Remove="config\cache\**" />
|
||||
<None Remove="config\logs\**" />
|
||||
<None Remove="config\covers\**" />
|
||||
<None Remove="config\bookmarks\**" />
|
||||
<None Remove="config\backups\**" />
|
||||
<None Remove="config\temp\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -131,6 +137,12 @@
|
|||
<Compile Remove="temp\**" />
|
||||
<Compile Remove="covers\**" />
|
||||
<Compile Remove="wwwroot\**" />
|
||||
<Compile Remove="config\cache\**" />
|
||||
<Compile Remove="config\logs\**" />
|
||||
<Compile Remove="config\covers\**" />
|
||||
<Compile Remove="config\bookmarks\**" />
|
||||
<Compile Remove="config\backups\**" />
|
||||
<Compile Remove="config\temp\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -146,6 +158,8 @@
|
|||
<EmbeddedResource Remove="config\temp\**" />
|
||||
<EmbeddedResource Remove="config\stats\**" />
|
||||
<EmbeddedResource Remove="wwwroot\**" />
|
||||
<EmbeddedResource Remove="config\cache\**" />
|
||||
<EmbeddedResource Remove="config\bookmarks\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -169,6 +183,7 @@
|
|||
<Content Update="bin\$(Configuration)\$(AssemblyName).xml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Remove="config\bookmarks\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,11 @@ public static class PolicyConstants
|
|||
/// Used to give a user ability to Change Restrictions on their account
|
||||
/// </summary>
|
||||
public const string ChangeRestrictionRole = "Change Restriction";
|
||||
/// <summary>
|
||||
/// Used to give a user ability to Login to their account
|
||||
/// </summary>
|
||||
public const string LoginRole = "Login";
|
||||
|
||||
public static readonly ImmutableArray<string> ValidRoles =
|
||||
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole);
|
||||
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ public class AccountController : BaseApiController
|
|||
|
||||
var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole);
|
||||
if (!roleResult.Succeeded) return BadRequest(result.Errors);
|
||||
await _userManager.AddToRoleAsync(user, PolicyConstants.LoginRole);
|
||||
|
||||
return new UserDto
|
||||
{
|
||||
|
|
@ -182,6 +183,8 @@ public class AccountController : BaseApiController
|
|||
.SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper());
|
||||
|
||||
if (user == null) return Unauthorized("Your credentials are not correct");
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized("Your account is disabled. Contact the server admin.");
|
||||
|
||||
var result = await _signInManager
|
||||
.CheckPasswordSignInAsync(user, loginDto.Password, true);
|
||||
|
|
|
|||
|
|
@ -338,7 +338,6 @@ public class LibraryController : BaseApiController
|
|||
library.IncludeInRecommended = dto.IncludeInRecommended;
|
||||
library.IncludeInSearch = dto.IncludeInSearch;
|
||||
library.ManageCollections = dto.ManageCollections;
|
||||
library.CollapseSeriesRelationships = dto.CollapseSeriesRelationships;
|
||||
|
||||
_unitOfWork.LibraryRepository.Update(library);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public class MetadataController : BaseApiController
|
|||
/// <param name="libraryIds">String separated libraryIds or null for all genres</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("genres")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
|
||||
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
|
|
@ -51,6 +52,7 @@ public class MetadataController : BaseApiController
|
|||
/// <param name="libraryIds">String separated libraryIds or null for all people</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("people")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
|
||||
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(string? libraryIds)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
|
|
@ -68,6 +70,7 @@ public class MetadataController : BaseApiController
|
|||
/// <param name="libraryIds">String separated libraryIds or null for all tags</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("tags")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
|
||||
public async Task<ActionResult<IList<TagDto>>> GetAllTags(string? libraryIds)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
|
|
@ -132,6 +135,7 @@ public class MetadataController : BaseApiController
|
|||
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("languages")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
|
||||
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
|
||||
{
|
||||
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||
|
|
@ -145,6 +149,7 @@ public class MetadataController : BaseApiController
|
|||
}
|
||||
|
||||
[HttpGet("all-languages")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
|
||||
public IEnumerable<LanguageDto> GetAllValidLanguages()
|
||||
{
|
||||
return CultureInfo.GetCultures(CultureTypes.AllCultures).Select(c =>
|
||||
|
|
|
|||
|
|
@ -38,18 +38,16 @@ public class UsersController : BaseApiController
|
|||
return BadRequest("Could not delete the user.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all users of this server
|
||||
/// </summary>
|
||||
/// <param name="includePending">This will include pending members</param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUsers()
|
||||
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUsers(bool includePending = false)
|
||||
{
|
||||
return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync());
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("pending")]
|
||||
public async Task<ActionResult<IEnumerable<MemberDto>>> GetPendingUsers()
|
||||
{
|
||||
return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync());
|
||||
return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync(!includePending));
|
||||
}
|
||||
|
||||
[HttpGet("myself")]
|
||||
|
|
@ -110,6 +108,7 @@ public class UsersController : BaseApiController
|
|||
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
|
||||
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
|
||||
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
|
||||
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
|
||||
|
||||
_unitOfWork.UserRepository.Update(existingPreferences);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ public class AgeRestrictionDto
|
|||
/// <summary>
|
||||
/// The maximum age rating a user has access to. -1 if not applicable
|
||||
/// </summary>
|
||||
public AgeRating AgeRating { get; set; } = AgeRating.NotApplicable;
|
||||
public required AgeRating AgeRating { get; set; } = AgeRating.NotApplicable;
|
||||
/// <summary>
|
||||
/// Are Unknowns explicitly allowed against age rating
|
||||
/// </summary>
|
||||
/// <remarks>Unknown is always lowest and default age rating. Setting this to false will ensure Teen age rating applies and unknowns are still filtered</remarks>
|
||||
public bool IncludeUnknowns { get; set; } = false;
|
||||
public required bool IncludeUnknowns { get; set; } = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace API.DTOs.Account;
|
||||
|
||||
|
|
@ -17,5 +18,4 @@ public record UpdateUserDto
|
|||
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
|
||||
/// </summary>
|
||||
public AgeRestrictionDto AgeRestriction { get; init; } = default!;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ public class MemberDto
|
|||
public int Id { get; init; }
|
||||
public string? Username { get; init; }
|
||||
public string? Email { get; init; }
|
||||
/// <summary>
|
||||
/// If the member is still pending or not
|
||||
/// </summary>
|
||||
public bool IsPending { get; init; }
|
||||
public AgeRestrictionDto? AgeRestriction { get; init; }
|
||||
public DateTime Created { get; init; }
|
||||
public DateTime LastActive { get; init; }
|
||||
|
|
|
|||
|
|
@ -24,7 +24,4 @@ public class UpdateLibraryDto
|
|||
public bool IncludeInSearch { get; init; }
|
||||
[Required]
|
||||
public bool ManageCollections { get; init; }
|
||||
[Required]
|
||||
public bool CollapseSeriesRelationships { get; init; }
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
|
|
@ -137,4 +137,9 @@ public class UserPreferencesDto
|
|||
/// </summary>
|
||||
[Required]
|
||||
public bool NoTransitions { get; set; } = false;
|
||||
/// <summary>
|
||||
/// When showing series, only parent series or series with no relationships will be returned
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool CollapseSeriesRelationships { get; set; } = false;
|
||||
}
|
||||
|
|
|
|||
36
API/Data/MigrateLoginRole.cs
Normal file
36
API/Data/MigrateLoginRole.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Entities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Added in v0.7.1.18
|
||||
/// </summary>
|
||||
public static class MigrateLoginRoles
|
||||
{
|
||||
/// <summary>
|
||||
/// Will not run if any users have the <see cref="PolicyConstants.LoginRole"/> role already
|
||||
/// </summary>
|
||||
/// <param name="unitOfWork"></param>
|
||||
/// <param name="userManager"></param>
|
||||
/// <param name="logger"></param>
|
||||
public static async Task Migrate(IUnitOfWork unitOfWork, UserManager<AppUser> userManager, ILogger<Program> logger)
|
||||
{
|
||||
var usersWithRole = await userManager.GetUsersInRoleAsync(PolicyConstants.LoginRole);
|
||||
if (usersWithRole.Count != 0) return;
|
||||
|
||||
logger.LogCritical("Running MigrateLoginRoles migration");
|
||||
|
||||
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync();
|
||||
foreach (var user in allUsers)
|
||||
{
|
||||
await userManager.RemoveFromRoleAsync(user, PolicyConstants.LoginRole);
|
||||
await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole);
|
||||
}
|
||||
|
||||
logger.LogInformation("MigrateLoginRoles migration complete");
|
||||
}
|
||||
}
|
||||
1858
API/Data/Migrations/20230310142630_MoveCollapseSeriesToUserPref.Designer.cs
generated
Normal file
1858
API/Data/Migrations/20230310142630_MoveCollapseSeriesToUserPref.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MoveCollapseSeriesToUserPref : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CollapseSeriesRelationships",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CollapseSeriesRelationships",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
|||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.10");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
|
|
@ -221,10 +221,10 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("BookReaderReadingDirection")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderWritingStyle")
|
||||
b.Property<bool>("BookReaderTapToPaginate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BookReaderTapToPaginate")
|
||||
b.Property<int>("BookReaderWritingStyle")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BookThemeName")
|
||||
|
|
@ -232,6 +232,9 @@ namespace API.Data.Migrations
|
|||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("Dark");
|
||||
|
||||
b.Property<bool>("CollapseSeriesRelationships")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EmulateBook")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
|
@ -601,9 +604,6 @@ namespace API.Data.Migrations
|
|||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CollapseSeriesRelationships")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
|
|
|||
|
|
@ -770,9 +770,9 @@ public class SeriesRepository : ISeriesRepository
|
|||
// NOTE: Why do we even have libraryId when the filter has the actual libraryIds?
|
||||
var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, queryContext);
|
||||
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
var onlyParentSeries = await _context.Library.AsNoTracking()
|
||||
.Where(l => filter.Libraries.Contains(l.Id))
|
||||
.AllAsync(l => l.CollapseSeriesRelationships);
|
||||
var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
|
||||
.Select(u => u.CollapseSeriesRelationships)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
|
||||
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ public interface IUserRepository
|
|||
void Add(AppUserBookmark bookmark);
|
||||
public void Delete(AppUser? user);
|
||||
void Delete(AppUserBookmark bookmark);
|
||||
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync();
|
||||
Task<IEnumerable<MemberDto>> GetPendingMemberDtosAsync();
|
||||
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
|
||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||
Task<bool> IsUserAdminAsync(AppUser? user);
|
||||
Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId);
|
||||
|
|
@ -329,10 +328,10 @@ public class UserRepository : IUserRepository
|
|||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync()
|
||||
public async Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true)
|
||||
{
|
||||
return await _context.Users
|
||||
.Where(u => u.EmailConfirmed)
|
||||
.Where(u => (emailConfirmed && u.EmailConfirmed) || !emailConfirmed)
|
||||
.Include(x => x.Libraries)
|
||||
.Include(r => r.UserRoles)
|
||||
.ThenInclude(r => r.Role)
|
||||
|
|
@ -344,45 +343,8 @@ public class UserRepository : IUserRepository
|
|||
Email = u.Email,
|
||||
Created = u.Created,
|
||||
LastActive = u.LastActive,
|
||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList()!,
|
||||
AgeRestriction = new AgeRestrictionDto()
|
||||
{
|
||||
AgeRating = u.AgeRestriction,
|
||||
IncludeUnknowns = u.AgeRestrictionIncludeUnknowns
|
||||
},
|
||||
Libraries = u.Libraries.Select(l => new LibraryDto
|
||||
{
|
||||
Name = l.Name,
|
||||
Type = l.Type,
|
||||
LastScanned = l.LastScanned,
|
||||
Folders = l.Folders.Select(x => x.Path).ToList()
|
||||
}).ToList()
|
||||
})
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of users that are considered Pending by invite. This means email is unconfirmed and they have never logged in
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<MemberDto>> GetPendingMemberDtosAsync()
|
||||
{
|
||||
return await _context.Users
|
||||
.Where(u => !u.EmailConfirmed && u.LastActive == DateTime.MinValue)
|
||||
.Include(x => x.Libraries)
|
||||
.Include(r => r.UserRoles)
|
||||
.ThenInclude(r => r.Role)
|
||||
.OrderBy(u => u.UserName)
|
||||
.Select(u => new MemberDto
|
||||
{
|
||||
Id = u.Id,
|
||||
Username = u.UserName,
|
||||
Email = u.Email,
|
||||
Created = u.Created,
|
||||
LastActive = u.LastActive,
|
||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList()!,
|
||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
|
||||
IsPending = !u.EmailConfirmed,
|
||||
AgeRestriction = new AgeRestrictionDto()
|
||||
{
|
||||
AgeRating = u.AgeRestriction,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
|||
/// </summary>
|
||||
public bool AgeRestrictionIncludeUnknowns { get; set; } = false;
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
[ConcurrencyCheck]
|
||||
public uint RowVersion { get; private set; }
|
||||
|
|
|
|||
|
|
@ -119,6 +119,10 @@ public class AppUserPreferences
|
|||
/// UI Site Global Setting: Should Kavita disable CSS transitions
|
||||
/// </summary>
|
||||
public bool NoTransitions { get; set; } = false;
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: When showing series, only parent series or series with no relationships will be returned
|
||||
/// </summary>
|
||||
public bool CollapseSeriesRelationships { get; set; } = false;
|
||||
|
||||
public AppUser AppUser { get; set; } = null!;
|
||||
public int AppUserId { get; set; }
|
||||
|
|
|
|||
|
|
@ -31,10 +31,6 @@ public class Library : IEntityDate
|
|||
/// Should this library create and manage collections from Metadata
|
||||
/// </summary>
|
||||
public bool ManageCollections { get; set; } = true;
|
||||
/// <summary>
|
||||
/// When showing series, only parent series or series with no relationships will be returned
|
||||
/// </summary>
|
||||
public bool CollapseSeriesRelationships { get; set; } = false;
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ namespace API.Entities;
|
|||
public class Person
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? NormalizedName { get; set; }
|
||||
public PersonRole Role { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string NormalizedName { get; set; }
|
||||
public required PersonRole Role { get; set; }
|
||||
|
||||
// Relationships
|
||||
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; } = null!;
|
||||
|
|
|
|||
57
API/Helpers/Builders/ChapterBuilder.cs
Normal file
57
API/Helpers/Builders/ChapterBuilder.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using System.Collections.Generic;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class ChapterBuilder : IEntityBuilder<Chapter>
|
||||
{
|
||||
private readonly Chapter _chapter;
|
||||
public Chapter Build() => _chapter;
|
||||
|
||||
public ChapterBuilder(string number, string? range=null)
|
||||
{
|
||||
_chapter = new Chapter()
|
||||
{
|
||||
Range = string.IsNullOrEmpty(range) ? number : range,
|
||||
Title = string.IsNullOrEmpty(range) ? number : range,
|
||||
Number = API.Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(number) + string.Empty,
|
||||
Files = new List<MangaFile>(),
|
||||
Pages = 1
|
||||
};
|
||||
}
|
||||
|
||||
public ChapterBuilder WithAgeRating(AgeRating rating)
|
||||
{
|
||||
_chapter.AgeRating = rating;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChapterBuilder WithPages(int pages)
|
||||
{
|
||||
_chapter.Pages = pages;
|
||||
return this;
|
||||
}
|
||||
public ChapterBuilder WithCoverImage(string cover)
|
||||
{
|
||||
_chapter.CoverImage = cover;
|
||||
return this;
|
||||
}
|
||||
public ChapterBuilder WithIsSpecial(bool isSpecial)
|
||||
{
|
||||
_chapter.IsSpecial = isSpecial;
|
||||
return this;
|
||||
}
|
||||
public ChapterBuilder WithTitle(string title)
|
||||
{
|
||||
_chapter.Title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChapterBuilder WithFile(MangaFile file)
|
||||
{
|
||||
_chapter.Files ??= new List<MangaFile>();
|
||||
_chapter.Files.Add(file);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
6
API/Helpers/Builders/EntityBuilder.cs
Normal file
6
API/Helpers/Builders/EntityBuilder.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace API.Helpers.Builders;
|
||||
|
||||
public interface IEntityBuilder<out T>
|
||||
{
|
||||
public T Build();
|
||||
}
|
||||
32
API/Helpers/Builders/PersonBuilder.cs
Normal file
32
API/Helpers/Builders/PersonBuilder.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using System.Collections.Generic;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class PersonBuilder : IEntityBuilder<Person>
|
||||
{
|
||||
private readonly Person _person;
|
||||
public Person Build() => _person;
|
||||
|
||||
public PersonBuilder(string name, PersonRole role)
|
||||
{
|
||||
_person = new Person()
|
||||
{
|
||||
Name = name.Trim(),
|
||||
NormalizedName = name.ToNormalized(),
|
||||
Role = role,
|
||||
ChapterMetadatas = new List<Chapter>(),
|
||||
SeriesMetadatas = new List<SeriesMetadata>()
|
||||
};
|
||||
}
|
||||
|
||||
public PersonBuilder WithSeriesMetadata(SeriesMetadata metadata)
|
||||
{
|
||||
_person.SeriesMetadatas ??= new List<SeriesMetadata>();
|
||||
_person.SeriesMetadatas.Add(metadata);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
71
API/Helpers/Builders/SeriesBuilder.cs
Normal file
71
API/Helpers/Builders/SeriesBuilder.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class SeriesBuilder : IEntityBuilder<Series>
|
||||
{
|
||||
private readonly Series _series;
|
||||
public Series Build()
|
||||
{
|
||||
_series.Pages = _series.Volumes.Sum(v => v.Chapters.Sum(c => c.Pages));
|
||||
return _series;
|
||||
}
|
||||
|
||||
public SeriesBuilder(string name)
|
||||
{
|
||||
_series = new Series()
|
||||
{
|
||||
Name = name,
|
||||
LocalizedName = name.ToNormalized(),
|
||||
OriginalName = name,
|
||||
SortName = name,
|
||||
NormalizedName = name.ToNormalized(),
|
||||
NormalizedLocalizedName = name.ToNormalized(),
|
||||
Metadata = new SeriesMetadata(),
|
||||
Volumes = new List<Volume>()
|
||||
};
|
||||
}
|
||||
|
||||
public SeriesBuilder WithLocalizedName(string localizedName)
|
||||
{
|
||||
_series.LocalizedName = localizedName;
|
||||
_series.NormalizedLocalizedName = localizedName.ToNormalized();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SeriesBuilder WithFormat(MangaFormat format)
|
||||
{
|
||||
_series.Format = format;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SeriesBuilder WithVolume(Volume volume)
|
||||
{
|
||||
_series.Volumes ??= new List<Volume>();
|
||||
_series.Volumes.Add(volume);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SeriesBuilder WithVolumes(List<Volume> volumes)
|
||||
{
|
||||
_series.Volumes = volumes;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SeriesBuilder WithMetadata(SeriesMetadata metadata)
|
||||
{
|
||||
_series.Metadata = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SeriesBuilder WithPages(int pages)
|
||||
{
|
||||
_series.Pages = pages;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
36
API/Helpers/Builders/SeriesMetadataBuilder.cs
Normal file
36
API/Helpers/Builders/SeriesMetadataBuilder.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using System.Collections.Generic;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
|
||||
{
|
||||
private readonly SeriesMetadata _seriesMetadata;
|
||||
public SeriesMetadata Build() => _seriesMetadata;
|
||||
|
||||
public SeriesMetadataBuilder()
|
||||
{
|
||||
_seriesMetadata = new SeriesMetadata()
|
||||
{
|
||||
CollectionTags = new List<CollectionTag>(),
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
People = new List<Person>()
|
||||
};
|
||||
}
|
||||
|
||||
public SeriesMetadataBuilder WithCollectionTag(CollectionTag tag)
|
||||
{
|
||||
_seriesMetadata.CollectionTags ??= new List<API.Entities.CollectionTag>();
|
||||
_seriesMetadata.CollectionTags.Add(tag);
|
||||
return this;
|
||||
}
|
||||
public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status)
|
||||
{
|
||||
_seriesMetadata.PublicationStatus = status;
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
43
API/Helpers/Builders/VolumeBuilder.cs
Normal file
43
API/Helpers/Builders/VolumeBuilder.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Data;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class VolumeBuilder : IEntityBuilder<Volume>
|
||||
{
|
||||
private readonly Volume _volume;
|
||||
public Volume Build() => _volume;
|
||||
|
||||
public VolumeBuilder(string volumeNumber)
|
||||
{
|
||||
_volume = DbFactory.Volume(volumeNumber);
|
||||
}
|
||||
|
||||
public VolumeBuilder WithName(string name)
|
||||
{
|
||||
_volume.Name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public VolumeBuilder WithNumber(int number)
|
||||
{
|
||||
_volume.Number = number;
|
||||
return this;
|
||||
}
|
||||
|
||||
public VolumeBuilder WithChapters(List<Chapter> chapters)
|
||||
{
|
||||
_volume.Chapters = chapters;
|
||||
return this;
|
||||
}
|
||||
|
||||
public VolumeBuilder WithChapter(Chapter chapter)
|
||||
{
|
||||
_volume.Chapters ??= new List<Chapter>();
|
||||
_volume.Chapters.Add(chapter);
|
||||
_volume.Pages = _volume.Chapters.Sum(c => c.Pages);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ public static class PersonHelper
|
|||
foreach (var person in existingPeople)
|
||||
{
|
||||
var existingPerson = removeAllExcept
|
||||
.FirstOrDefault(p => person.NormalizedName != null && p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName));
|
||||
.FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName));
|
||||
if (existingPerson == null)
|
||||
{
|
||||
action?.Invoke(person);
|
||||
|
|
@ -100,8 +100,9 @@ public static class PersonHelper
|
|||
/// <param name="person"></param>
|
||||
public static void AddPersonIfNotExists(ICollection<Person> metadataPeople, Person person)
|
||||
{
|
||||
if (string.IsNullOrEmpty(person.Name)) return;
|
||||
var existingPerson = metadataPeople.SingleOrDefault(p =>
|
||||
p.NormalizedName == person.Name?.ToNormalized() && p.Role == person.Role);
|
||||
p.NormalizedName == person.Name.ToNormalized() && p.Role == person.Role);
|
||||
if (existingPerson == null)
|
||||
{
|
||||
metadataPeople.Add(person);
|
||||
|
|
|
|||
|
|
@ -348,8 +348,9 @@ public class BookmarkService : IBookmarkService
|
|||
/// <param name="filename">The file to convert</param>
|
||||
/// <param name="targetFolder">Full path to where files should be stored or any stem</param>
|
||||
/// <returns></returns>
|
||||
private async Task<string> SaveAsWebP(string imageDirectory, string filename, string targetFolder)
|
||||
public async Task<string> SaveAsWebP(string imageDirectory, string filename, string targetFolder)
|
||||
{
|
||||
// This must be Public as it's used in via Hangfire as a background task
|
||||
var fullSourcePath = _directoryService.FileSystem.Path.Join(imageDirectory, filename);
|
||||
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(filename).Name, string.Empty);
|
||||
|
||||
|
|
|
|||
|
|
@ -182,7 +182,12 @@ public class SeriesService : ISeriesService
|
|||
return true;
|
||||
}
|
||||
|
||||
if (await _unitOfWork.CommitAsync() && updateSeriesMetadataDto.CollectionTags != null)
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
// Trigger code to cleanup tags, collections, people, etc
|
||||
await _taskScheduler.CleanupDbEntries();
|
||||
|
||||
if (updateSeriesMetadataDto.CollectionTags != null)
|
||||
{
|
||||
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public interface ITaskScheduler
|
|||
Task RunStatCollection();
|
||||
void ScanSiteThemes();
|
||||
Task CovertAllCoversToWebP();
|
||||
Task CleanupDbEntries();
|
||||
}
|
||||
public class TaskScheduler : ITaskScheduler
|
||||
{
|
||||
|
|
@ -230,6 +231,11 @@ public class TaskScheduler : ITaskScheduler
|
|||
|
||||
#endregion
|
||||
|
||||
public async Task CleanupDbEntries()
|
||||
{
|
||||
await _cleanupService.CleanupDbEntries();
|
||||
}
|
||||
|
||||
public void ScanLibraries()
|
||||
{
|
||||
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
|
||||
|
|
|
|||
|
|
@ -236,6 +236,11 @@ public class ParseScannedFiles
|
|||
p.Key.Format == info.Format)
|
||||
.Key;
|
||||
|
||||
if (existingName == null)
|
||||
{
|
||||
return info.Series;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(existingName.Name))
|
||||
{
|
||||
return existingName.Name;
|
||||
|
|
@ -297,7 +302,7 @@ public class ParseScannedFiles
|
|||
|
||||
_logger.LogDebug("[ScannerService] Found {Count} files for {Folder}", files.Count, folder);
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.FileScanProgressEvent(folder, libraryName, ProgressEventType.Updated));
|
||||
MessageFactory.FileScanProgressEvent($"{files.Count} files in {folder}", libraryName, ProgressEventType.Updated));
|
||||
if (files.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("[ScannerService] {Folder} is empty or is no longer in this location", folder);
|
||||
|
|
|
|||
|
|
@ -433,7 +433,7 @@ public class ProcessSeries : IProcessSeries
|
|||
var genres = chapters.SelectMany(c => c.Genres).ToList();
|
||||
GenreHelper.KeepOnlySameGenreBetweenLists(series.Metadata.Genres.ToList(), genres, genre =>
|
||||
{
|
||||
if (series.Metadata.GenresLocked) return;
|
||||
if (series.Metadata.GenresLocked) return; // NOTE: Doesn't it make sense to do the locked skip outside this loop?
|
||||
series.Metadata.Genres.Remove(genre);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -520,12 +520,14 @@ public class ScannerService : IScannerService
|
|||
|
||||
var scanElapsedTime = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, TrackFiles, forceUpdate);
|
||||
|
||||
// NOTE: This runs sync after every file is scanned
|
||||
foreach (var task in processTasks)
|
||||
{
|
||||
await task();
|
||||
}
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(string.Empty, library.Name, ProgressEventType.Ended));
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.FileScanProgressEvent(string.Empty, library.Name, ProgressEventType.Ended));
|
||||
|
||||
_logger.LogInformation("[ScannerService] Finished file scan in {ScanAndUpdateTime} milliseconds. Updating database", scanElapsedTime);
|
||||
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ public class Startup
|
|||
|
||||
|
||||
logger.LogInformation("Running Migrations");
|
||||
|
||||
// Only run this if we are upgrading
|
||||
await MigrateChangePasswordRoles.Migrate(unitOfWork, userManager);
|
||||
await MigrateRemoveExtraThemes.Migrate(unitOfWork, themeService);
|
||||
|
|
@ -235,6 +236,9 @@ public class Startup
|
|||
// v0.7
|
||||
await MigrateBrokenGMT1Dates.Migrate(unitOfWork, dataContext, logger);
|
||||
|
||||
// v0.7.2
|
||||
await MigrateLoginRoles.Migrate(unitOfWork, userManager, logger);
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||
installVersion.Value = BuildInfo.Version.ToString();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue