mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
More Metadata Stuff (#3537)
This commit is contained in:
parent
8d3dcc637e
commit
53b13da0c9
@ -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";
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -2,5 +2,5 @@
|
||||
|
||||
public class UpdateSeriesMetadataDto
|
||||
{
|
||||
public SeriesMetadataDto SeriesMetadata { get; set; } = default!;
|
||||
public SeriesMetadataDto SeriesMetadata { get; set; } = null!;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
3398
API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs
generated
Normal file
3398
API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs
Normal file
61
API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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 =>
|
||||
|
@ -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
|
||||
|
@ -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>()));
|
||||
|
||||
|
||||
|
@ -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>();
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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)
|
||||
|
16
UI/Web/src/app/_pipes/library-name.pipe.ts
Normal file
16
UI/Web/src/app/_pipes/library-name.pipe.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
35
UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts
Normal file
35
UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts
Normal 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');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
16
UI/Web/src/app/admin/_models/metadata-setting-field.ts
Normal file
16
UI/Web/src/app/admin/_models/metadata-setting-field.ts
Normal 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[];
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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)])
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
45
openapi.json
45
openapi.json
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user