More Metadata Stuff (#3537)

This commit is contained in:
Joe Milazzo 2025-02-08 15:37:12 -06:00 committed by GitHub
parent 8d3dcc637e
commit 53b13da0c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 4123 additions and 129 deletions

View File

@ -23,4 +23,8 @@ public static class EasyCacheProfiles
/// External Series metadata for Kavita+ recommendation
/// </summary>
public const string KavitaPlusExternalSeries = "kavita+externalSeries";
/// <summary>
/// Match Series metadata for Kavita+ metadata download
/// </summary>
public const string KavitaPlusMatchSeries = "kavita+matchSeries";
}

View File

@ -19,11 +19,13 @@ using API.Helpers;
using API.Services;
using API.Services.Plus;
using EasyCaching.Core;
using Hangfire;
using Kavita.Common;
using Kavita.Common.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
@ -39,14 +41,17 @@ public class SeriesController : BaseApiController
private readonly ILicenseService _licenseService;
private readonly ILocalizationService _localizationService;
private readonly IExternalMetadataService _externalMetadataService;
private readonly IHostEnvironment _environment;
private readonly IEasyCachingProvider _externalSeriesCacheProvider;
private readonly IEasyCachingProvider _matchSeriesCacheProvider;
private const string CacheKey = "externalSeriesData_";
private const string MatchSeriesCacheKey = "matchSeries_";
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork,
ISeriesService seriesService, ILicenseService licenseService,
IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService,
IExternalMetadataService externalMetadataService)
IExternalMetadataService externalMetadataService, IHostEnvironment environment)
{
_logger = logger;
_taskScheduler = taskScheduler;
@ -55,8 +60,10 @@ public class SeriesController : BaseApiController
_licenseService = licenseService;
_localizationService = localizationService;
_externalMetadataService = externalMetadataService;
_environment = environment;
_externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
_matchSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusMatchSeries);
}
/// <summary>
@ -501,7 +508,7 @@ public class SeriesController : BaseApiController
/// <param name="ageRating"></param>
/// <returns></returns>
/// <remarks>This is cached for an hour</remarks>
[ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})]
[ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = ["ageRating"])]
[HttpGet("age-rating")]
public async Task<ActionResult<string>> GetAgeRating(int ageRating)
{
@ -625,7 +632,17 @@ public class SeriesController : BaseApiController
[HttpPost("match")]
public async Task<ActionResult<IList<ExternalSeriesMatchDto>>> MatchSeries(MatchSeriesDto dto)
{
return Ok(await _externalMetadataService.MatchSeries(dto));
var cacheKey = $"{MatchSeriesCacheKey}-{dto.SeriesId}-{dto.Query}";
var results = await _matchSeriesCacheProvider.GetAsync<IList<ExternalSeriesMatchDto>>(cacheKey);
if (results.HasValue && !_environment.IsDevelopment())
{
return Ok(results.Value);
}
var ret = await _externalMetadataService.MatchSeries(dto);
await _matchSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(5));
return Ok(ret);
}
/// <summary>
@ -635,9 +652,9 @@ public class SeriesController : BaseApiController
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("update-match")]
public async Task<ActionResult> UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int aniListId)
public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int aniListId)
{
await _externalMetadataService.FixSeriesMatch(seriesId, aniListId);
BackgroundJob.Enqueue(() => _externalMetadataService.FixSeriesMatch(seriesId, aniListId));
return Ok();
}

View File

@ -560,6 +560,7 @@ public class SettingsController : BaseApiController
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings();
existingMetadataSetting.Enabled = dto.Enabled;
existingMetadataSetting.EnableSummary = dto.EnableSummary;
existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName;
existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus;
existingMetadataSetting.EnableRelationships = dto.EnableRelationships;
existingMetadataSetting.EnablePeople = dto.EnablePeople;
@ -573,6 +574,7 @@ public class SettingsController : BaseApiController
existingMetadataSetting.Blacklist = dto.Blacklist.DistinctBy(d => d.ToNormalized()).ToList() ?? [];
existingMetadataSetting.Whitelist = dto.Whitelist.DistinctBy(d => d.ToNormalized()).ToList() ?? [];
existingMetadataSetting.Overrides = dto.Overrides.ToList() ?? [];
// Handle Field Mappings
if (dto.FieldMappings != null)

View File

@ -498,7 +498,7 @@ public class UploadController : BaseApiController
var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id);
if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
await _coverDbService.SetPersonCoverImage(person, uploadFileDto.Url, true);
await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, true);
return Ok();
}
catch (Exception e)

View File

@ -1,9 +1,11 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
using NotImplementedException = System.NotImplementedException;
namespace API.DTOs.KavitaPlus.Metadata;
public class MetadataSettingsDto
{
/// <summary>
@ -35,6 +37,10 @@ public class MetadataSettingsDto
/// Allow setting the Localized name
/// </summary>
public bool EnableLocalizedName { get; set; }
/// <summary>
/// Allow setting the cover image
/// </summary>
public bool EnableCoverImage { get; set; }
// Need to handle the Genre/tags stuff
public bool EnableGenres { get; set; } = true;
@ -54,6 +60,10 @@ public class MetadataSettingsDto
/// A list of rules that allow mapping a genre/tag to another genre/tag
/// </summary>
public List<MetadataFieldMappingDto> FieldMappings { get; set; }
/// <summary>
/// A list of overrides that will enable writing to locked fields
/// </summary>
public List<MetadataSettingField> Overrides { get; set; }
/// <summary>
/// Do not allow any Genre/Tag in this list to be written to Kavita
@ -67,4 +77,25 @@ public class MetadataSettingsDto
/// Which Roles to allow metadata downloading for
/// </summary>
public List<PersonRole> PersonRoles { get; set; }
/// <summary>
/// Override list contains this field
/// </summary>
/// <param name="field"></param>
/// <returns></returns>
public bool HasOverride(MetadataSettingField field)
{
return Overrides.Contains(field);
}
/// <summary>
/// If this Person role is allowed to be written
/// </summary>
/// <param name="character"></param>
/// <returns></returns>
public bool IsPersonAllowed(PersonRole character)
{
return PersonRoles.Contains(character);
}
}

View File

@ -1,9 +1,18 @@
namespace API.DTOs.KavitaPlus.Metadata;
public enum CharacterRole
{
Main = 0,
Supporting = 1,
Background = 2
}
public class SeriesCharacter
{
public string Name { get; set; }
public required string Description { get; set; }
public required string Url { get; set; }
public string? ImageUrl { get; set; }
public CharacterRole Role { get; set; }
}

View File

@ -2,5 +2,5 @@
public class UpdateSeriesMetadataDto
{
public SeriesMetadataDto SeriesMetadata { get; set; } = default!;
public SeriesMetadataDto SeriesMetadata { get; set; } = null!;
}

View File

@ -204,11 +204,15 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.HasForeignKey(smp => smp.PersonId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<SeriesMetadataPeople>()
.Property(b => b.OrderWeight)
.HasDefaultValue(0);
builder.Entity<MetadataSettings>()
.Property(x => x.AgeRatingMappings)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<Dictionary<string, AgeRating>>(v, JsonSerializerOptions.Default)
v => JsonSerializer.Deserialize<Dictionary<string, AgeRating>>(v, JsonSerializerOptions.Default) ?? new Dictionary<string, AgeRating>()
);
// Ensure blacklist is stored as a JSON array
@ -216,13 +220,19 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.Property(x => x.Blacklist)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default) ?? new List<string>()
);
builder.Entity<MetadataSettings>()
.Property(x => x.Whitelist)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default) ?? new List<string>()
);
builder.Entity<MetadataSettings>()
.Property(x => x.Overrides)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<MetadataSettingField>>(v, JsonSerializerOptions.Default) ?? new List<MetadataSettingField>()
);
// Configure one-to-many relationship
@ -235,6 +245,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<MetadataSettings>()
.Property(b => b.Enabled)
.HasDefaultValue(true);
builder.Entity<MetadataSettings>()
.Property(b => b.EnableCoverImage)
.HasDefaultValue(true);
}
#nullable enable

View File

@ -29,10 +29,18 @@ public static class ManualMigrateBlacklistTableToSeries
.Include(s => s.Series.ExternalSeriesMetadata)
.Select(s => s.Series)
.ToListAsync();
foreach (var series in blacklistedSeries)
{
series.IsBlacklisted = true;
series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata() { SeriesId = series.Id };
if (series.ExternalSeriesMetadata.AniListId > 0)
{
series.IsBlacklisted = false;
logger.LogInformation("{SeriesName} was in Blacklist table, but has valid AniList Id, not blacklisting", series.Name);
}
context.Series.Entry(series).State = EntityState.Modified;
}
// Remove everything in SeriesBlacklist (it will be removed in another migration)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class MoreMetadtaSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "KavitaPlusConnection",
table: "SeriesMetadataPeople",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "OrderWeight",
table: "SeriesMetadataPeople",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "EnableCoverImage",
table: "MetadataSettings",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<string>(
name: "Overrides",
table: "MetadataSettings",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "KavitaPlusConnection",
table: "SeriesMetadataPeople");
migrationBuilder.DropColumn(
name: "OrderWeight",
table: "SeriesMetadataPeople");
migrationBuilder.DropColumn(
name: "EnableCoverImage",
table: "MetadataSettings");
migrationBuilder.DropColumn(
name: "Overrides",
table: "MetadataSettings");
}
}
}

View File

@ -1652,6 +1652,11 @@ namespace API.Data.Migrations
b.Property<string>("Blacklist")
.HasColumnType("TEXT");
b.Property<bool>("EnableCoverImage")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("EnableGenres")
.HasColumnType("INTEGER");
@ -1684,10 +1689,13 @@ namespace API.Data.Migrations
b.Property<bool>("FirstLastPeopleNaming")
.HasColumnType("INTEGER");
b.Property<string>("Overrides")
.HasColumnType("TEXT");
b.PrimitiveCollection<string>("PersonRoles")
.HasColumnType("TEXT");
b.PrimitiveCollection<string>("Whitelist")
b.Property<string>("Whitelist")
.HasColumnType("TEXT");
b.HasKey("Id");
@ -2114,6 +2122,14 @@ namespace API.Data.Migrations
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<bool>("KavitaPlusConnection")
.HasColumnType("INTEGER");
b.Property<int>("OrderWeight")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.HasKey("SeriesMetadataId", "PersonId", "Role");
b.HasIndex("PersonId");

View File

@ -225,6 +225,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
{
return await _context.Series
.Include(s => s.Library)
.Include(s => s.ExternalSeriesMetadata)
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
.FilterMatchState(filter.MatchStateOption)
.OrderBy(s => s.NormalizedName)

View File

@ -309,6 +309,7 @@ public static class Seed
EnableGenres = true,
EnableLocalizedName = false,
FirstLastPeopleNaming = true,
EnableCoverImage = true,
PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]
};
await context.MetadataSettings.AddAsync(existing);

View File

@ -1,8 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using API.Entities.Enums;
namespace API.Entities;
/// <summary>
/// Represents which field that can be written to as an override when already locked
/// </summary>
public enum MetadataSettingField
{
Summary = 1,
PublicationStatus = 2,
StartDate = 3,
Genres = 4,
Tags = 5,
LocalizedName = 6,
Covers = 7,
AgeRating = 8,
People = 9
}
/// <summary>
/// Handles the metadata settings for Kavita+
/// </summary>
@ -38,6 +55,10 @@ public class MetadataSettings
/// Allow setting the Localized name
/// </summary>
public bool EnableLocalizedName { get; set; }
/// <summary>
/// Allow setting the cover image
/// </summary>
public bool EnableCoverImage { get; set; }
// Need to handle the Genre/tags stuff
public bool EnableGenres { get; set; } = true;
@ -58,6 +79,11 @@ public class MetadataSettings
/// </summary>
public List<MetadataFieldMapping> FieldMappings { get; set; }
/// <summary>
/// A list of overrides that will enable writing to locked fields
/// </summary>
public List<MetadataSettingField> Overrides { get; set; }
/// <summary>
/// Do not allow any Genre/Tag in this list to be written to Kavita
/// </summary>

View File

@ -2,6 +2,7 @@
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Entities.Metadata;
using API.Services.Plus;
namespace API.Entities;
@ -18,29 +19,30 @@ public class Person : IHasCoverImage
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
public string Description { get; set; }
/// <summary>
/// ASIN for person
/// </summary>
/// <remarks>Can be used for Amazon author lookup</remarks>
public string? Asin { get; set; }
public string Description { get; set; }
/// <summary>
/// ASIN for person
/// </summary>
/// <remarks>Can be used for Amazon author lookup</remarks>
public string? Asin { get; set; }
/// <summary>
/// https://anilist.co/staff/{AniListId}/
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public int AniListId { get; set; } = 0;
/// <summary>
/// https://myanimelist.net/people/{MalId}/
/// https://myanimelist.net/character/{MalId}/CharacterName
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public long MalId { get; set; } = 0;
/// <summary>
/// https://hardcover.app/authors/{HardcoverId}
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public string? HardcoverId { get; set; }
/// <summary>
/// https://anilist.co/staff/{AniListId}/
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public int AniListId { get; set; } = 0;
/// <summary>
/// https://myanimelist.net/people/{MalId}/
/// https://myanimelist.net/character/{MalId}/CharacterName
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public long MalId { get; set; } = 0;
/// <summary>
/// https://hardcover.app/authors/{HardcoverId}
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public string? HardcoverId { get; set; }
/// <summary>
/// https://metron.cloud/creator/{slug}/
/// </summary>

View File

@ -1,5 +1,6 @@
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Services.Plus;
namespace API.Entities;
@ -11,5 +12,14 @@ public class SeriesMetadataPeople
public int PersonId { get; set; }
public virtual Person Person { get; set; }
/// <summary>
/// The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches
/// </summary>
public bool KavitaPlusConnection { get; set; } = false;
/// <summary>
/// A weight that allows lower numbers to sort first
/// </summary>
public int OrderWeight { get; set; }
public required PersonRole Role { get; set; }
}

View File

@ -90,6 +90,7 @@ public static class ApplicationServiceExtensions
options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries);
options.UseInMemory(EasyCacheProfiles.License);
options.UseInMemory(EasyCacheProfiles.LicenseInfo);
options.UseInMemory(EasyCacheProfiles.KavitaPlusMatchSeries);
});
services.AddMemoryCache(options =>

View File

@ -293,7 +293,7 @@ public static class QueryableExtensions
.Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted),
MatchStateOption.NotMatched => query.
Include(s => s.ExternalSeriesMetadata)
.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted),
.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted && !s.DontMatch),
MatchStateOption.Error => query.Where(s => s.IsBlacklisted),
MatchStateOption.DontMatch => query.Where(s => s.DontMatch),
_ => query

View File

@ -121,8 +121,8 @@ public class AutoMapperProfiles : Profile
// Map Characters
.ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Character)
.Select(cp => cp.Person)
.OrderBy(p => p.NormalizedName)))
.OrderBy(cp => cp.OrderWeight)
.Select(cp => cp.Person)))
// Map Pencillers
.ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People
.Where(cp => cp.Role == PersonRole.Penciller)
@ -369,6 +369,7 @@ public class AutoMapperProfiles : Profile
CreateMap<MetadataSettings, MetadataSettingsDto>()
.ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List<string>()))
.ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List<string>()))
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));

View File

@ -45,7 +45,7 @@ public interface IExternalMetadataService
/// <param name="seriesId"></param>
/// <param name="libraryType"></param>
/// <returns></returns>
Task GetNewSeriesData(int seriesId, LibraryType libraryType);
Task FetchSeriesMetadata(int seriesId, LibraryType libraryType);
Task<IList<MalStackDto>> GetStacksForUser(int userId);
Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto);
@ -118,7 +118,7 @@ public class ExternalMetadataService : IExternalMetadataService
foreach (var seriesId in ids)
{
var libraryType = libTypes[seriesId];
await GetNewSeriesData(seriesId, libraryType);
await FetchSeriesMetadata(seriesId, libraryType);
await Task.Delay(1500);
count++;
}
@ -131,7 +131,7 @@ public class ExternalMetadataService : IExternalMetadataService
/// </summary>
/// <param name="seriesId"></param>
/// <param name="libraryType"></param>
public async Task GetNewSeriesData(int seriesId, LibraryType libraryType)
public async Task FetchSeriesMetadata(int seriesId, LibraryType libraryType)
{
if (!IsPlusEligible(libraryType)) return;
if (!await _licenseService.HasActiveLicense()) return;
@ -146,8 +146,9 @@ public class ExternalMetadataService : IExternalMetadataService
}
_logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId);
// Prefetch SeriesDetail data
var metadata = await GetSeriesDetailPlus(seriesId, libraryType);
await GetSeriesDetailPlus(seriesId, libraryType);
}
@ -211,7 +212,7 @@ public class ExternalMetadataService : IExternalMetadataService
Format = series.Format == MangaFormat.Epub ? PlusMediaFormat.LightNovel : PlusMediaFormat.Manga,
Query = dto.Query,
SeriesName = series.Name,
AlternativeNames = altNames,
AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(),
Year = series.Metadata.ReleaseYear,
AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series),
MalId = potentialMalId ?? ScrobblingService.GetMalId(series),
@ -403,7 +404,7 @@ public class ExternalMetadataService : IExternalMetadataService
var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
.WithKavitaPlusHeaders(license)
.PostJsonAsync(data)
.ReceiveJson<SeriesDetailPlusApiDto>();
.ReceiveJson<SeriesDetailPlusApiDto>(); // This returns an AniListSeries and Match returns ExternalSeriesDto
// Clear out existing results
@ -446,6 +447,8 @@ public class ExternalMetadataService : IExternalMetadataService
var madeMetadataModification = false;
if (result.Series != null && series.Library.AllowMetadataMatching)
{
externalSeriesMetadata.Series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId);
if (madeMetadataModification)
{
@ -499,21 +502,41 @@ public class ExternalMetadataService : IExternalMetadataService
{
var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
if (!settings.Enabled) return false;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Related);
if (series == null) return false;
var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser();
_logger.LogInformation("Writing External metadata to Series {SeriesName}", series.Name);
var madeModification = false;
if (!series.Metadata.SummaryLocked && string.IsNullOrEmpty(series.Metadata.Summary) && settings.EnableSummary)
if (settings.EnableLocalizedName && (!series.LocalizedNameLocked || settings.HasOverride(MetadataSettingField.LocalizedName)))
{
// We need to make the best appropriate guess
if (externalMetadata.Name == series.Name)
{
// Choose closest (usually last) synonym
series.LocalizedName = externalMetadata.Synonyms.Last();
}
else
{
series.LocalizedName = externalMetadata.Name;
}
madeModification = true;
}
if (settings.EnableSummary && (!series.Metadata.SummaryLocked ||
settings.HasOverride(MetadataSettingField.Summary)))
{
series.Metadata.Summary = CleanSummary(externalMetadata.Summary);
madeModification = true;
}
if (settings.EnableStartDate && !series.Metadata.ReleaseYearLocked && externalMetadata.StartDate.HasValue)
if (settings.EnableStartDate && externalMetadata.StartDate.HasValue && (!series.Metadata.ReleaseYearLocked ||
settings.HasOverride(MetadataSettingField.StartDate)))
{
series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year;
madeModification = true;
@ -543,7 +566,7 @@ public class ExternalMetadataService : IExternalMetadataService
.Where(g => !settings.Blacklist.Contains(g))
.ToList();
if (settings.EnableGenres && !series.Metadata.GenresLocked && processedGenres.Count > 0)
if (settings.EnableGenres && processedGenres.Count > 0 && (!series.Metadata.GenresLocked || settings.HasOverride(MetadataSettingField.Genres)))
{
_logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name);
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList();
@ -578,7 +601,7 @@ public class ExternalMetadataService : IExternalMetadataService
.ToList();
// Set the tags for the series and ensure they are in the DB
if (settings.EnableTags && !series.Metadata.TagsLocked && processedTags.Count > 0)
if (settings.EnableTags && processedTags.Count > 0 && (!series.Metadata.TagsLocked || settings.HasOverride(MetadataSettingField.Tags)))
{
_logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name);
var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize)))
@ -596,7 +619,7 @@ public class ExternalMetadataService : IExternalMetadataService
#region Age Rating
if (!series.Metadata.AgeRatingLocked)
if (!series.Metadata.AgeRatingLocked || settings.HasOverride(MetadataSettingField.AgeRating))
{
try
{
@ -644,64 +667,92 @@ public class ExternalMetadataService : IExternalMetadataService
// Roles: Character Design, Story, Art
var allWriters = externalMetadata.Staff
var upstreamWriters = externalMetadata.Staff
.Where(s => s.Role is "Story" or "Story & Art")
.ToList();
var writers = allWriters
var writers = upstreamWriters
.Select(w => new PersonDto()
{
Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = CleanSummary(w.Description),
})
.Concat(series.Metadata.People.Where(p => p.Role == PersonRole.Writer).Select(p => _mapper.Map<PersonDto>(p)))
.Concat(series.Metadata.People
.Where(p => p.Role == PersonRole.Writer)
.Where(p => !p.KavitaPlusConnection)
.Select(p => _mapper.Map<PersonDto>(p.Person))
)
.DistinctBy(p => Parser.Normalize(p.Name))
.ToList();
// NOTE: PersonRoles can be a hashset
if (!series.Metadata.WriterLocked && writers.Count > 0 && settings.PersonRoles.Contains(PersonRole.Writer))
if (writers.Count > 0 && settings.IsPersonAllowed(PersonRole.Writer) && (!series.Metadata.WriterLocked || settings.HasOverride(MetadataSettingField.People)))
{
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork);
foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.Writer))
{
var meta = upstreamWriters.FirstOrDefault(c => c.Name == person.Person.Name);
person.OrderWeight = 0;
if (meta != null)
{
person.KavitaPlusConnection = true;
}
}
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await DownloadAndSetCovers(allWriters);
await DownloadAndSetCovers(upstreamWriters);
madeModification = true;
}
var allArtists = externalMetadata.Staff
var upstreamArtists = externalMetadata.Staff
.Where(s => s.Role is "Art" or "Story & Art")
.ToList();
var artists = allArtists
var artists = upstreamArtists
.Select(w => new PersonDto()
{
Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = CleanSummary(w.Description),
})
.Concat(series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => _mapper.Map<PersonDto>(p)))
.Concat(series.Metadata.People
.Where(p => p.Role == PersonRole.CoverArtist)
.Where(p => !p.KavitaPlusConnection)
.Select(p => _mapper.Map<PersonDto>(p.Person))
)
.DistinctBy(p => Parser.Normalize(p.Name))
.ToList();
if (!series.Metadata.CoverArtistLocked && artists.Count > 0 && settings.PersonRoles.Contains(PersonRole.CoverArtist))
if (artists.Count > 0 && settings.IsPersonAllowed(PersonRole.CoverArtist) && (!series.Metadata.CoverArtistLocked || settings.HasOverride(MetadataSettingField.People)))
{
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork);
foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist))
{
var meta = upstreamArtists.FirstOrDefault(c => c.Name == person.Person.Name);
person.OrderWeight = 0;
if (meta != null)
{
person.KavitaPlusConnection = true;
}
}
// Download the image and save it
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await DownloadAndSetCovers(allArtists);
await DownloadAndSetCovers(upstreamArtists);
madeModification = true;
}
if (externalMetadata.Characters != null && settings.PersonRoles.Contains(PersonRole.Character))
if (externalMetadata.Characters != null && settings.IsPersonAllowed(PersonRole.Character) && (!series.Metadata.CharacterLocked ||
settings.HasOverride(MetadataSettingField.People)))
{
var characters = externalMetadata.Characters
.Select(w => new PersonDto()
@ -710,27 +761,50 @@ public class ExternalMetadataService : IExternalMetadataService
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
Description = CleanSummary(w.Description),
})
.Concat(series.Metadata.People.Where(p => p.Role == PersonRole.Character).Select(p => _mapper.Map<PersonDto>(p)))
.Concat(series.Metadata.People
.Where(p => p.Role == PersonRole.Character)
// Need to ensure existing people are retained, but we overwrite anything from a bad match
.Where(p => !p.KavitaPlusConnection)
.Select(p => _mapper.Map<PersonDto>(p.Person))
)
.DistinctBy(p => Parser.Normalize(p.Name))
.ToList();
if (!series.Metadata.CharacterLocked && characters.Count > 0)
if (characters.Count > 0)
{
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork);
foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character))
{
// Set a sort order based on their role
var characterMeta = externalMetadata.Characters?.FirstOrDefault(c => c.Name == spPerson.Person.Name);
spPerson.OrderWeight = 0;
if (characterMeta != null)
{
spPerson.KavitaPlusConnection = true;
spPerson.OrderWeight = characterMeta.Role switch
{
CharacterRole.Main => 0,
CharacterRole.Supporting => 1,
CharacterRole.Background => 2,
_ => 99 // Default for unknown roles
};
}
}
// Download the image and save it
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
foreach (var character in externalMetadata.Characters)
foreach (var character in externalMetadata.Characters ?? [])
{
var aniListId = ScrobblingService.ExtractId<int>(character.Url, ScrobblingService.AniListCharacterWebsite);
if (aniListId <= 0) continue;
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId);
if (person != null && !string.IsNullOrEmpty(character.ImageUrl) && string.IsNullOrEmpty(person.CoverImage))
{
await _coverDbService.SetPersonCoverImage(person, character.ImageUrl, false);
await _coverDbService.SetPersonCoverByUrl(person, character.ImageUrl, false);
}
}
@ -743,7 +817,8 @@ public class ExternalMetadataService : IExternalMetadataService
#region Publication Status
if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus)
if (settings.EnablePublicationStatus && (!series.Metadata.PublicationStatusLocked ||
settings.HasOverride(MetadataSettingField.PublicationStatus)))
{
try
{
@ -765,7 +840,6 @@ public class ExternalMetadataService : IExternalMetadataService
if (settings.EnableRelationships && externalMetadata.Relations != null && defaultAdmin != null)
{
foreach (var relation in externalMetadata.Relations)
{
var relatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName(
@ -817,9 +891,20 @@ public class ExternalMetadataService : IExternalMetadataService
}
#endregion
#region Series Cover
// This must not allow cover image locked to be off after downloading, else it will call every time a match is hit
if (!string.IsNullOrEmpty(externalMetadata.CoverUrl) && (!series.CoverImageLocked || settings.HasOverride(MetadataSettingField.Covers)))
{
await DownloadSeriesCovers(series, externalMetadata.CoverUrl);
}
#endregion
return madeModification;
}
private static RelationKind GetReverseRelation(RelationKind relation)
{
return relation switch
@ -830,6 +915,18 @@ public class ExternalMetadataService : IExternalMetadataService
};
}
private async Task DownloadSeriesCovers(Series series, string coverUrl)
{
try
{
await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception downloading cover image for Series {SeriesName} ({SeriesId})", series.Name, series.Id);
}
}
private async Task DownloadAndSetCovers(List<SeriesStaffDto> people)
{
foreach (var staff in people)
@ -839,7 +936,7 @@ public class ExternalMetadataService : IExternalMetadataService
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value);
if (person != null && !string.IsNullOrEmpty(staff.ImageUrl) && string.IsNullOrEmpty(person.CoverImage))
{
await _coverDbService.SetPersonCoverImage(person, staff.ImageUrl, false);
await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false);
}
}
}
@ -929,7 +1026,13 @@ public class ExternalMetadataService : IExternalMetadataService
return mapping.DestinationValue ?? (mapping.ExcludeFromSource ? null : value);
}
private static AgeRating DetermineAgeRating(IEnumerable<string> values, Dictionary<string, AgeRating> mappings)
/// <summary>
/// Returns the highest age rating from all tags/genres based on user-supplied mappings
/// </summary>
/// <param name="values">A combo of all tags/genres</param>
/// <param name="mappings"></param>
/// <returns></returns>
public static AgeRating DetermineAgeRating(IEnumerable<string> values, Dictionary<string, AgeRating> mappings)
{
// Find highest age rating from mappings
mappings ??= new Dictionary<string, AgeRating>();

View File

@ -121,12 +121,6 @@ public class SeriesService : ISeriesService
series.Metadata ??= new SeriesMetadataBuilder()
.Build();
if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating)
{
series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata.AgeRating;
series.Metadata.AgeRatingLocked = true;
}
if (NumberHelper.IsValidYear(updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear)
{
series.Metadata.ReleaseYear = updateSeriesMetadataDto.SeriesMetadata.ReleaseYear;
@ -173,7 +167,7 @@ public class SeriesService : ISeriesService
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>();
series.Metadata.Genres ??= [];
GenreHelper.UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, genre =>
{
series.Metadata.Genres.Add(genre);
@ -181,7 +175,7 @@ public class SeriesService : ISeriesService
}
else
{
series.Metadata.Genres = new List<Genre>();
series.Metadata.Genres = [];
}
@ -190,7 +184,7 @@ public class SeriesService : ISeriesService
var allTags = (await _unitOfWork.TagRepository
.GetAllTagsByNameAsync(updateSeriesMetadataDto.SeriesMetadata.Tags.Select(t => Parser.Normalize(t.Title))))
.ToList();
series.Metadata.Tags ??= new List<Tag>();
series.Metadata.Tags ??= [];
TagHelper.UpdateTagList(updateSeriesMetadataDto.SeriesMetadata?.Tags, series, allTags, tag =>
{
series.Metadata.Tags.Add(tag);
@ -198,14 +192,33 @@ public class SeriesService : ISeriesService
}
else
{
series.Metadata.Tags = new List<Tag>();
series.Metadata.Tags = [];
}
if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata?.AgeRating)
{
series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata?.AgeRating ?? AgeRating.Unknown;
series.Metadata.AgeRatingLocked = true;
}
else
{
if (!series.Metadata.AgeRatingLocked)
{
var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title));
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings);
if (updatedRating > series.Metadata.AgeRating)
{
series.Metadata.AgeRating = updatedRating;
}
}
}
if (updateSeriesMetadataDto.SeriesMetadata != null)
{
if (PersonHelper.HasAnyPeople(updateSeriesMetadataDto.SeriesMetadata))
{
series.Metadata.People ??= new List<SeriesMetadataPeople>();
series.Metadata.People ??= [];
// Writers
if (!series.Metadata.WriterLocked)
@ -279,6 +292,12 @@ public class SeriesService : ISeriesService
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator, _unitOfWork);
}
// Characters
if (!series.Metadata.CharacterLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Characters, PersonRole.Character, _unitOfWork);
}
}
series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked;
@ -295,6 +314,7 @@ public class SeriesService : ISeriesService
series.Metadata.PencillerLocked = updateSeriesMetadataDto.SeriesMetadata.PencillerLocked;
series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublisherLocked;
series.Metadata.TranslatorLocked = updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked;
series.Metadata.LocationLocked = updateSeriesMetadataDto.SeriesMetadata.LocationLocked;
series.Metadata.CoverArtistLocked = updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked;
series.Metadata.WriterLocked = updateSeriesMetadataDto.SeriesMetadata.WriterLocked;
series.Metadata.SummaryLocked = updateSeriesMetadataDto.SeriesMetadata.SummaryLocked;

View File

@ -28,7 +28,8 @@ public interface ICoverDbService
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat);
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url);
Task SetPersonCoverImage(Person person, string url, bool fromBase64 = true);
Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true);
Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true);
}
@ -460,7 +461,8 @@ public class CoverDbService : ICoverDbService
return null;
}
public async Task SetPersonCoverImage(Person person, string url, bool fromBase64 = true)
public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true)
{
if (!string.IsNullOrEmpty(url))
{
@ -490,6 +492,42 @@ public class CoverDbService : ICoverDbService
}
}
/// <summary>
/// Sets the series cover by url
/// </summary>
/// <param name="series"></param>
/// <param name="url"></param>
/// <param name="fromBase64"></param>
public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true)
{
if (!string.IsNullOrEmpty(url))
{
var filePath = await CreateThumbnail(url, $"{ImageService.GetSeriesFormat(series.Id)}", fromBase64);
if (!string.IsNullOrEmpty(filePath))
{
series.CoverImage = filePath;
series.CoverImageLocked = true;
_imageService.UpdateColorScape(series);
_unitOfWork.SeriesRepository.Update(series);
}
}
else
{
series.CoverImage = string.Empty;
series.CoverImageLocked = false;
_imageService.UpdateColorScape(series);
_unitOfWork.SeriesRepository.Update(series);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false);
}
}
private async Task<string> CreateThumbnail(string url, string filename, bool fromBase64 = true)
{
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();

View File

@ -194,7 +194,7 @@ public class ProcessSeries : IProcessSeries
{
// See if any recommendations can link up to the series and pre-fetch external metadata for the series
BackgroundJob.Enqueue(() =>
_externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type));
_externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type));
await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded,
MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false);
@ -298,7 +298,19 @@ public class ProcessSeries : IProcessSeries
}
// Set the AgeRating as highest in all the comicInfos
if (!series.Metadata.AgeRatingLocked) series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating);
if (!series.Metadata.AgeRatingLocked)
{
series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating);
// Get the MetadataSettings and apply Age Rating Mappings here
var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title));
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings);
if (updatedRating > series.Metadata.AgeRating)
{
series.Metadata.AgeRating = updatedRating;
}
}
DeterminePublicationStatus(series, chapters);
@ -318,7 +330,6 @@ public class ProcessSeries : IProcessSeries
await UpdateCollectionTags(series, firstChapter);
}
#region PeopleAndTagsAndGenres
if (!series.Metadata.WriterLocked)
{
@ -414,6 +425,7 @@ public class ProcessSeries : IProcessSeries
}
#endregion
}
private async Task UpdateCollectionTags(Series series, Chapter firstChapter)

View File

@ -0,0 +1,16 @@
import {inject, Pipe, PipeTransform} from '@angular/core';
import {LibraryService} from "../_services/library.service";
import {Observable} from "rxjs";
@Pipe({
name: 'libraryName',
standalone: true
})
export class LibraryNamePipe implements PipeTransform {
private readonly libraryService = inject(LibraryService);
transform(libraryId: number): Observable<string> {
return this.libraryService.getLibraryName(libraryId);
}
}

View File

@ -0,0 +1,35 @@
import { Pipe, PipeTransform } from '@angular/core';
import {MetadataSettingField} from "../admin/_models/metadata-setting-field";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'metadataSettingFiled',
standalone: true
})
export class MetadataSettingFiledPipe implements PipeTransform {
transform(value: MetadataSettingField): string {
switch (value) {
case MetadataSettingField.AgeRating:
return translate('metadata-setting-field-pipe.age-rating');
case MetadataSettingField.People:
return translate('metadata-setting-field-pipe.people');
case MetadataSettingField.Covers:
return translate('metadata-setting-field-pipe.covers');
case MetadataSettingField.Summary:
return translate('metadata-setting-field-pipe.summary');
case MetadataSettingField.PublicationStatus:
return translate('metadata-setting-field-pipe.publication-status');
case MetadataSettingField.StartDate:
return translate('metadata-setting-field-pipe.start-date');
case MetadataSettingField.Genres:
return translate('metadata-setting-field-pipe.genres');
case MetadataSettingField.Tags:
return translate('metadata-setting-field-pipe.tags');
case MetadataSettingField.LocalizedName:
return translate('metadata-setting-field-pipe.localized-name');
}
}
}

View File

@ -0,0 +1,16 @@
export enum MetadataSettingField {
Summary = 1,
PublicationStatus = 2,
StartDate = 3,
Genres = 4,
Tags = 5,
LocalizedName = 6,
Covers = 7,
AgeRating = 8,
People = 9
}
export const allMetadataSettingField = Object.keys(MetadataSettingField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as MetadataSettingField[];

View File

@ -1,5 +1,6 @@
import {AgeRating} from "../../_models/metadata/age-rating";
import {PersonRole} from "../../_models/metadata/person";
import {MetadataSettingField} from "./metadata-setting-field";
export enum MetadataFieldType {
Genre = 0,
@ -22,6 +23,7 @@ export interface MetadataSettings {
enableRelationships: boolean;
enablePeople: boolean;
enableStartDate: boolean;
enableCoverImage: boolean;
enableLocalizedName: boolean;
enableGenres: boolean;
enableTags: boolean;
@ -31,4 +33,5 @@ export interface MetadataSettings {
blacklist: Array<string>;
whitelist: Array<string>;
personRoles: Array<PersonRole>;
overrides: Array<MetadataSettingField>;
}

View File

@ -24,7 +24,7 @@
[footerHeight]="50"
>
<ngx-datatable-column name="lastModifiedUtc" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ngx-datatable-column name="series.name" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-name-header')}}
</ng-template>
@ -34,6 +34,15 @@
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="series.libraryId" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('library-name-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.series.libraryId | libraryName | async}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="status" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
@ -70,9 +79,13 @@
<ngx-datatable-column name="" [width]="20" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('actions-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, item.series)"></app-card-actionables>
<button class="btn btn-icon" (click)="fixMatch(item.series)">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="visually-hidden">{{t('match-alt', {seriesName: item.series.name})}}</span>
</button>
</ng-template>
</ngx-datatable-column>

View File

@ -4,9 +4,7 @@ import {Router} from "@angular/router";
import {TranslocoDirective} from "@jsverse/transloco";
import {ImageComponent} from "../../shared/image/image.component";
import {ImageService} from "../../_services/image.service";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {Series} from "../../_models/series";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {ActionService} from "../../_services/action.service";
import {ManageService} from "../../_services/manage.service";
import {ManageMatchSeries} from "../../_models/kavitaplus/manage-match-series";
@ -19,46 +17,47 @@ import {MatchStateOptionPipe} from "../../_pipes/match-state.pipe";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {debounceTime, distinctUntilChanged, switchMap, tap} from "rxjs";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter";
import {ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {LibraryNamePipe} from "../../_pipes/library-name.pipe";
import {AsyncPipe} from "@angular/common";
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {ScanSeriesEvent} from "../../_models/events/scan-series-event";
@Component({
selector: 'app-manage-matched-metadata',
standalone: true,
imports: [
TranslocoDirective,
ImageComponent,
CardActionablesComponent,
VirtualScrollerModule,
ReactiveFormsModule,
Select2Module,
MatchStateOptionPipe,
UtcToLocalTimePipe,
DefaultValuePipe,
NgxDatatableModule,
],
imports: [
TranslocoDirective,
ImageComponent,
VirtualScrollerModule,
ReactiveFormsModule,
Select2Module,
MatchStateOptionPipe,
UtcToLocalTimePipe,
DefaultValuePipe,
NgxDatatableModule,
LibraryNamePipe,
AsyncPipe,
],
templateUrl: './manage-matched-metadata.component.html',
styleUrl: './manage-matched-metadata.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageMatchedMetadataComponent implements OnInit {
protected readonly MatchState = MatchStateOption;
protected readonly ColumnMode = ColumnMode;
protected readonly allMatchStates = allMatchStates.filter(m => m !== MatchStateOption.Matched); // Matched will have too many
private readonly licenseService = inject(LicenseService);
private readonly actionFactory = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly router = inject(Router);
private readonly manageService = inject(ManageService);
private readonly messageHub = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly imageService = inject(ImageService);
isLoading: boolean = true;
data: Array<ManageMatchSeries> = [];
actions: Array<ActionItem<Series>> = this.actionFactory.getSeriesActions(this.fixMatch.bind(this))
.filter(item => item.action === Action.Match);
filterGroup = new FormGroup({
'matchState': new FormControl(MatchStateOption.Error, []),
});
@ -71,6 +70,15 @@ export class ManageMatchedMetadataComponent implements OnInit {
return;
}
this.messageHub.messages$.subscribe(message => {
if (message.event !== EVENTS.ScanSeries) return;
const evt = message.payload as ScanSeriesEvent;
if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) {
this.loadData();
}
});
this.filterGroup.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
@ -86,7 +94,6 @@ export class ManageMatchedMetadataComponent implements OnInit {
).subscribe();
this.loadData().subscribe();
});
}
@ -108,21 +115,11 @@ export class ManageMatchedMetadataComponent implements OnInit {
}));
}
performAction(action: ActionItem<Series>, series: Series) {
if (action.callback) {
action.callback(action, series);
}
}
fixMatch(actionItem: ActionItem<Series>, series: Series) {
fixMatch(series: Series) {
this.actionService.matchSeries(series, result => {
if (!result) return;
this.loadData().subscribe();
});
}
protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber;
protected readonly ScrobbleEventType = ScrobbleEventType;
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
protected readonly ColumnMode = ColumnMode;
}

View File

@ -29,6 +29,18 @@
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableLocalizedName'); as formControl) {
<app-setting-switch [title]="t('localized-name-label')" [subtitle]="t('localized-name-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="localized-name" type="checkbox" class="form-check-input" formControlName="enableLocalizedName">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enablePublicationStatus'); as formControl) {
<app-setting-switch [title]="t('derive-publication-status-label')" [subtitle]="t('derive-publication-status-tooltip')">
@ -65,6 +77,18 @@
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableCoverImage'); as formControl) {
<app-setting-switch [title]="t('enable-cover-image-label')" [subtitle]="t('enable-cover-image-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-cover-image" type="checkbox" class="form-check-input" formControlName="enableCoverImage">
</div>
</ng-template>
</app-setting-switch>
}
</div>
@if(settingsForm.get('enablePeople'); as formControl) {
<div class="setting-section-break"></div>
@ -183,13 +207,13 @@
<div formArrayName="ageRatingMappings">
@for(mapping of ageRatingMappings.controls; track mapping; let i = $index) {
<div [formGroupName]="i" class="row mb-2">
<div class="col-md-4">
<div class="col-md-4 d-flex align-items-center justify-content-center">
<input type="text" class="form-control" formControlName="str" autocomplete="off" />
</div>
<div class="col-md-2">
<div class="col-md-2 d-flex align-items-center justify-content-center">
<i class="fa fa-arrow-right" aria-hidden="true"></i>
</div>
<div class="col-md-4">
<div class="col-md-4 d-flex align-items-center justify-content-center">
<select class="form-select" formControlName="rating">
@for (ageRating of ageRatings; track ageRating.value) {
<option [value]="ageRating.value">
@ -202,13 +226,21 @@
<button class="btn btn-icon" (click)="removeAgeRatingMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
@if($last) {
<button class="btn btn-icon" (click)="addAgeRatingMapping()">
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
}
</div>
</div>
} @empty {
<button class="btn btn-secondary" (click)="addAgeRatingMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-age-rating-mapping-label')}}
</button>
}
<button class="btn btn-secondary" (click)="addAgeRatingMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-age-rating-mapping-label')}}
</button>
</div>
<div class="setting-section-break"></div>
@ -252,15 +284,39 @@
<button class="btn btn-icon" (click)="removeFieldMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
@if ($last) {
<button class="btn btn-icon" (click)="addFieldMapping()">
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
}
</div>
</div>
} @empty {
<button class="btn btn-secondary" (click)="addFieldMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-field-mapping-label')}}
</button>
}
<button class="btn btn-secondary float-end" (click)="addFieldMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-field-mapping-label')}}
</button>
</div>
<div class="setting-section-break"></div>
@if (settingsForm.get('overrides')) {
<h5>{{t('overrides-label')}}</h5>
<p>{{t('overrides-description')}}</p>
<div class="row g-0 mt-4 mb-4" formArrayName="overrides">
@for(field of allMetadataSettingFields; track field; let i = $index) {
<div class="col-md-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" [formControlName]="'override_' + i" [id]="'override-' + field">
<label class="form-check-label" [for]="'override-' + field">{{ field | metadataSettingFiled }}</label>
</div>
</div>
}
</div>
}
</form>
}
</ng-container>

View File

@ -17,6 +17,8 @@ import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-setti
import {PersonRole} from "../../_models/metadata/person";
import {PersonRolePipe} from "../../_pipes/person-role.pipe";
import {NgClass} from "@angular/common";
import {allMetadataSettingField} from "../_models/metadata-setting-field";
import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe";
@Component({
@ -31,6 +33,7 @@ import {NgClass} from "@angular/common";
TagBadgeComponent,
AgeRatingPipe,
PersonRolePipe,
MetadataSettingFiledPipe,
],
templateUrl: './manage-metadata-settings.component.html',
styleUrl: './manage-metadata-settings.component.scss',
@ -52,6 +55,7 @@ export class ManageMetadataSettingsComponent implements OnInit {
fieldMappings = this.fb.array([]);
personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character];
isLoaded = false;
allMetadataSettingFields = allMetadataSettingField;
ngOnInit(): void {
this.metadataService.getAllAgeRatings().subscribe(ratings => {
@ -66,6 +70,7 @@ export class ManageMetadataSettingsComponent implements OnInit {
this.settingService.getMetadataSettings().subscribe(settings => {
this.settingsForm.addControl('enabled', new FormControl(settings.enabled, []));
this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, []));
this.settingsForm.addControl('enableLocalizedName', new FormControl(settings.enableLocalizedName, []));
this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, []));
this.settingsForm.addControl('enableRelations', new FormControl(settings.enableRelationships, []));
this.settingsForm.addControl('enableGenres', new FormControl(settings.enableGenres, []));
@ -73,6 +78,7 @@ export class ManageMetadataSettingsComponent implements OnInit {
this.settingsForm.addControl('enableRelationships', new FormControl(settings.enableRelationships, []));
this.settingsForm.addControl('enablePeople', new FormControl(settings.enablePeople, []));
this.settingsForm.addControl('enableStartDate', new FormControl(settings.enableStartDate, []));
this.settingsForm.addControl('enableCoverImage', new FormControl(settings.enableCoverImage, []));
this.settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), []));
this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), []));
@ -86,6 +92,15 @@ export class ManageMetadataSettingsComponent implements OnInit {
)
));
this.settingsForm.addControl('overrides', this.fb.group(
Object.fromEntries(
this.allMetadataSettingFields.map((role, index) => [
`override_${index}`,
this.fb.control((settings.overrides || []).includes(role)),
])
)
));
if (settings.ageRatingMappings) {
Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => {
@ -171,7 +186,10 @@ export class ManageMetadataSettingsComponent implements OnInit {
whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()),
personRoles: Object.entries(this.settingsForm.get('personRoles')!.value)
.filter(([_, value]) => value)
.map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)])
.map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]),
overrides: Object.entries(this.settingsForm.get('overrides')!.value)
.filter(([_, value]) => value)
.map(([key, _]) => this.allMetadataSettingFields[parseInt(key.split('_')[1], 10)])
}
}

View File

@ -759,14 +759,17 @@
"description": "All applicable Series that can be matched with External Metadata reside here. Kavita will prefetch or refresh series metadata, 50 series per 24 hours daily.",
"status-header": "Status",
"series-name-header": "Series",
"library-name-header": "Library",
"valid-until-header": "Next Refresh",
"actions-header": "Actions",
"matched-status-label": "Matched",
"unmatched-status-label": "Not Matched",
"blacklist-status-label": "Needs Manual Match",
"dont-match-status-label": "{{dont-match-label}}",
"all-status-label": "All",
"dont-match-label": "Don't Match",
"no-data": "{{common.no-data}}"
"no-data": "{{common.no-data}}",
"match-alt": "Match {{seriesName}}"
},
"manage-user-tokens": {
@ -785,12 +788,16 @@
"enabled-tooltip": "Allow Kavita to download metadata and write to it's database.",
"summary-label": "Summary",
"summary-tooltip": "Allow Summary to be written when the field is unlocked.",
"localized-name-label": "Localized Series Name",
"localized-name-tooltip": "Allow Localized Name to be written when the field is unlocked. Kavita will attempt to make the best guess.",
"derive-publication-status-label": "Publication Status",
"derive-publication-status-tooltip": "Allow Publication Status to be derived from Total Chapter/Volume counts.",
"enable-relations-label": "Relationships",
"enable-relations-tooltip": "Allow Series Relationships to be <b>added</b>.",
"enable-people-label": "People",
"enable-people-tooltip": "Allow People (Characters, Writers, etc) to be <b>added</b>. All people include images.",
"enable-cover-image-label": "Cover Image",
"enable-cover-image-tooltip": "Allow Kavita to write the cover image for the Series",
"enable-start-date-label": "Start Date",
"enable-start-date-tooltip": "Allow Start Date of Series to be written to the Series",
"enable-genres-label": "Genres",
@ -812,7 +819,10 @@
"field-mapping-description": "Setup rules for certain strings found in Genre/Tag field and map it to a new string in Genre/Tag and optionally remove it from the Source list. Only applicable when Genre/Tag are enabled to be written.",
"first-last-name-label": "First Last Naming",
"first-last-name-tooltip": "Ensure People's names are written First then Last",
"person-roles-label": "Roles"
"person-roles-label": "Roles",
"overrides-label": "Overrides",
"overrides-description": "Allow Kavita to write over locked fields"
},
"book-line-overlay": {
@ -2613,6 +2623,19 @@
"hours": "Hours"
},
"metadata-setting-field-pipe": {
"covers": "Covers",
"age-rating": "{{metadata-fields.age-rating-title}}",
"people": "{{tabs.people-tab}}",
"summary": "{{filter-field-pipe.summary}}",
"publication-status": "{{edit-series-modal.publication-status-title}}",
"start-date": "{{manage-metadata-settings.enable-start-date-label}}",
"genres": "{{metadata-fields.genres-title}}",
"tags": "{{metadata-fields.tags-title}}",
"localized-name": "{{edit-series-modal.localized-name-label}}"
},
"actionable": {
"scan-library": "Scan Library",
"scan-library-tooltip": "Scan library for changes. Use force scan to force checking every folder",

View File

@ -2,7 +2,7 @@
"openapi": "3.0.1",
"info": {
"title": "Kavita",
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.10",
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.11",
"license": {
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
@ -20002,6 +20002,10 @@
"type": "boolean",
"description": "Allow setting the Localized name"
},
"enableCoverImage": {
"type": "boolean",
"description": "Allow setting the cover image"
},
"enableGenres": {
"type": "boolean"
},
@ -20048,6 +20052,27 @@
"description": "A list of rules that allow mapping a genre/tag to another genre/tag",
"nullable": true
},
"overrides": {
"type": "array",
"items": {
"enum": [
1,
2,
3,
4,
5,
6,
7,
8,
9
],
"type": "integer",
"description": "Represents which field that can be written to as an override when already locked",
"format": "int32"
},
"description": "A list of overrides that will enable writing to locked fields",
"nullable": true
},
"blacklist": {
"type": "array",
"items": {
@ -21728,6 +21753,15 @@
"imageUrl": {
"type": "string",
"nullable": true
},
"role": {
"enum": [
0,
1,
2
],
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
@ -22407,6 +22441,15 @@
"person": {
"$ref": "#/components/schemas/Person"
},
"kavitaPlusConnection": {
"type": "boolean",
"description": "The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches"
},
"orderWeight": {
"type": "integer",
"description": "A weight that allows lower numbers to sort first",
"format": "int32"
},
"role": {
"enum": [
1,