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:
Joe Milazzo 2023-03-10 19:09:38 -06:00 committed by GitHub
parent c62e594792
commit bd19b282d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 2186 additions and 239 deletions

View file

@ -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>

View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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 =>

View file

@ -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);

View file

@ -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;
}

View file

@ -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!;
}

View file

@ -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; }

View file

@ -24,7 +24,4 @@ public class UpdateLibraryDto
public bool IncludeInSearch { get; init; }
[Required]
public bool ManageCollections { get; init; }
[Required]
public bool CollapseSeriesRelationships { get; init; }
}

View file

@ -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;
}

View 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");
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -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");

View file

@ -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,

View file

@ -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,

View file

@ -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; }

View file

@ -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; }

View file

@ -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; }

View file

@ -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!;

View 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;
}
}

View file

@ -0,0 +1,6 @@
namespace API.Helpers.Builders;
public interface IEntityBuilder<out T>
{
public T Build();
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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)
{

View file

@ -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))

View file

@ -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);

View file

@ -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);
});

View file

@ -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);

View file

@ -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();