Metadata Downloading (#3525)

This commit is contained in:
Joe Milazzo 2025-02-05 16:16:44 -06:00 committed by GitHub
parent eb66763078
commit f4fd7230ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
108 changed files with 6296 additions and 484 deletions

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
@ -20,7 +21,7 @@ using NSubstitute;
namespace API.Tests;
public abstract class AbstractDbTest
public abstract class AbstractDbTest : IDisposable
{
protected readonly DbConnection _connection;
protected readonly DataContext _context;
@ -28,6 +29,7 @@ public abstract class AbstractDbTest
protected const string CacheDirectory = "C:/kavita/config/cache/";
protected const string CacheLongDirectory = "C:/kavita/config/cache-long/";
protected const string CoverImageDirectory = "C:/kavita/config/covers/";
protected const string BackupDirectory = "C:/kavita/config/backups/";
protected const string LogDirectory = "C:/kavita/config/logs/";
@ -38,21 +40,22 @@ public abstract class AbstractDbTest
protected AbstractDbTest()
{
var contextOptions = new DbContextOptionsBuilder()
var contextOptions = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(CreateInMemoryDatabase())
.Options;
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
_context = new DataContext(contextOptions);
_context.Database.EnsureCreated(); // Ensure DB schema is created
Task.Run(SeedDb).GetAwaiter().GetResult();
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper();
// Set up Hangfire to use in-memory storage for testing
GlobalConfiguration.Configuration.UseInMemoryStorage();
_unitOfWork = new UnitOfWork(_context, mapper, null);
}
@ -66,29 +69,43 @@ public abstract class AbstractDbTest
private async Task<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
try
{
await _context.Database.EnsureCreatedAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
setting.Value = CacheDirectory;
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
setting.Value = CacheDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
setting.Value = BackupDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
setting.Value = BackupDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
setting.Value = BookmarkDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
setting.Value = BookmarkDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync();
setting.Value = "10";
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync();
setting.Value = "10";
_context.ServerSetting.Update(setting);
_context.ServerSetting.Update(setting);
_context.Library.Add(new LibraryBuilder("Manga")
.WithFolderPath(new FolderPathBuilder("C:/data/").Build())
.Build());
return await _context.SaveChangesAsync() > 0;
_context.Library.Add(new LibraryBuilder("Manga")
.WithFolderPath(new FolderPathBuilder(DataDirectory).Build())
.Build());
await _context.SaveChangesAsync();
await Seed.SeedMetadataSettings(_context);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[SeedDb] Error: {ex.Message}");
return false;
}
}
protected abstract Task ResetDb();
@ -99,6 +116,7 @@ public abstract class AbstractDbTest
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CacheLongDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(BookmarkDirectory);
@ -109,4 +127,10 @@ public abstract class AbstractDbTest
return fileSystem;
}
public void Dispose()
{
_context.Dispose();
_connection.Dispose();
}
}

View File

@ -932,7 +932,8 @@ public class SeriesFilterTests : AbstractDbTest
var seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
Substitute.For<IScrobblingService>(), Substitute.For<ILocalizationService>());
Substitute.For<IScrobblingService>(), Substitute.For<ILocalizationService>()
, Substitute.For<IImageService>());
// Select 0 Rating
var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2);

View File

@ -56,7 +56,7 @@ public class SeriesServiceTests : AbstractDbTest
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
Substitute.For<IScrobblingService>(), locService);
Substitute.For<IScrobblingService>(), locService, Substitute.For<IImageService>());
}
#region Setup

View File

@ -197,6 +197,7 @@
<Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Folder Include="Data\ManualMigrations\v0.8.3\" />
<Folder Include="Extensions\KavitaPlus\" />
<None Include="I18N\**" />
</ItemGroup>

View File

@ -81,7 +81,8 @@ public class LibraryController : BaseApiController
.WithIncludeInDashboard(dto.IncludeInDashboard)
.WithManageCollections(dto.ManageCollections)
.WithManageReadingLists(dto.ManageReadingLists)
.WIthAllowScrobbling(dto.AllowScrobbling)
.WithAllowScrobbling(dto.AllowScrobbling)
.WithAllowMetadataMatching(dto.AllowMetadataMatching)
.Build();
library.LibraryFileTypes = dto.FileGroupTypes

View File

@ -631,13 +631,13 @@ public class SeriesController : BaseApiController
/// <summary>
/// This will perform the fix match
/// </summary>
/// <param name="dto"></param>
/// <param name="aniListId"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("update-match")]
public async Task<ActionResult> UpdateSeriesMatch(ExternalSeriesDetailDto dto, [FromQuery] int seriesId)
public async Task<ActionResult> UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int aniListId)
{
await _externalMetadataService.FixSeriesMatch(seriesId, dto);
await _externalMetadataService.FixSeriesMatch(seriesId, aniListId);
return Ok();
}

View File

@ -5,6 +5,7 @@ using System.Net;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Email;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
@ -534,4 +535,73 @@ public class SettingsController : BaseApiController
if (string.IsNullOrEmpty(user?.Email)) return BadRequest("Your account has no email on record. Cannot email.");
return Ok(await _emailService.SendTestEmail(user!.Email));
}
/// <summary>
/// Get the metadata settings for Kavita+ users.
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("metadata-settings")]
public async Task<ActionResult<MetadataSettingsDto>> GetMetadataSettings()
{
return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto());
}
/// <summary>
/// Update the metadata settings for Kavita+ users
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("metadata-settings")]
public async Task<ActionResult<MetadataSettingsDto>> UpdateMetadataSettings(MetadataSettingsDto dto)
{
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings();
existingMetadataSetting.Enabled = dto.Enabled;
existingMetadataSetting.EnableSummary = dto.EnableSummary;
existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus;
existingMetadataSetting.EnableRelationships = dto.EnableRelationships;
existingMetadataSetting.EnablePeople = dto.EnablePeople;
existingMetadataSetting.EnableStartDate = dto.EnableStartDate;
existingMetadataSetting.EnableGenres = dto.EnableGenres;
existingMetadataSetting.EnableTags = dto.EnableTags;
existingMetadataSetting.PersonRoles = dto.PersonRoles;
existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming;
existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? [];
existingMetadataSetting.Blacklist = dto.Blacklist.DistinctBy(d => d.ToNormalized()).ToList() ?? [];
existingMetadataSetting.Whitelist = dto.Whitelist.DistinctBy(d => d.ToNormalized()).ToList() ?? [];
// Handle Field Mappings
if (dto.FieldMappings != null)
{
// Clear existing mappings
existingMetadataSetting.FieldMappings ??= [];
_unitOfWork.SettingsRepository.RemoveRange(existingMetadataSetting.FieldMappings);
existingMetadataSetting.FieldMappings.Clear();
// Add new mappings
foreach (var mappingDto in dto.FieldMappings)
{
existingMetadataSetting.FieldMappings.Add(new MetadataFieldMapping
{
SourceType = mappingDto.SourceType,
DestinationType = mappingDto.DestinationType,
SourceValue = mappingDto.SourceValue,
DestinationValue = mappingDto.DestinationValue,
ExcludeFromSource = mappingDto.ExcludeFromSource
});
}
}
// Save changes
await _unitOfWork.CommitAsync();
// Return updated settings
return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto());
}
}

View File

@ -8,6 +8,7 @@ using API.DTOs.Uploads;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Tasks.Metadata;
using API.SignalR;
using Flurl.Http;
using Microsoft.AspNetCore.Authorization;
@ -31,11 +32,12 @@ public class UploadController : BaseApiController
private readonly IEventHub _eventHub;
private readonly IReadingListService _readingListService;
private readonly ILocalizationService _localizationService;
private readonly ICoverDbService _coverDbService;
/// <inheritdoc />
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger,
ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService,
ILocalizationService localizationService)
ILocalizationService localizationService, ICoverDbService coverDbService)
{
_unitOfWork = unitOfWork;
_imageService = imageService;
@ -45,6 +47,7 @@ public class UploadController : BaseApiController
_eventHub = eventHub;
_readingListService = readingListService;
_localizationService = localizationService;
_coverDbService = coverDbService;
}
/// <summary>
@ -495,34 +498,8 @@ 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"));
if (!string.IsNullOrEmpty(uploadFileDto.Url))
{
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetPersonFormat(uploadFileDto.Id)}");
if (!string.IsNullOrEmpty(filePath))
{
person.CoverImage = filePath;
person.CoverImageLocked = true;
_imageService.UpdateColorScape(person);
_unitOfWork.PersonRepository.Update(person);
}
}
else
{
person.CoverImage = string.Empty;
person.CoverImageLocked = false;
_imageService.UpdateColorScape(person);
_unitOfWork.PersonRepository.Update(person);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false);
return Ok();
}
await _coverDbService.SetPersonCoverImage(person, uploadFileDto.Url, true);
return Ok();
}
catch (Exception e)
{

View File

@ -135,6 +135,14 @@ public class UsersController : BaseApiController
existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
if (await _licenseService.HasActiveLicense())
{
existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled;
existingPreferences.WantToReadSync = preferencesDto.WantToReadSync;
}
if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id)
{
var theme = await _unitOfWork.SiteThemeRepository.GetTheme(preferencesDto.Theme!.Id);
@ -147,6 +155,7 @@ public class UsersController : BaseApiController
existingPreferences.Locale = preferencesDto.Locale;
}
_unitOfWork.UserRepository.Update(existingPreferences);
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-pref"));

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using API.DTOs.Recommendation;
using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail;
@ -9,6 +10,7 @@ internal class SeriesDetailPlusApiDto
public IEnumerable<MediaRecommendationDto> Recommendations { get; set; }
public IEnumerable<UserReviewDto> Reviews { get; set; }
public IEnumerable<RatingDto> Ratings { get; set; }
public ExternalSeriesDetailDto? Series { get; set; }
public int? AniListId { get; set; }
public long? MalId { get; set; }
}

View File

@ -1,10 +1,15 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Scrobbling;
using API.Services.Plus;
namespace API.DTOs.Recommendation;
#nullable enable
/// <summary>
/// This is AniListSeries
/// </summary>
public class ExternalSeriesDetailDto
{
public string Name { get; set; }
@ -18,7 +23,15 @@ public class ExternalSeriesDetailDto
public IList<SeriesStaffDto> Staff { get; set; }
public IList<MetadataTagDto> Tags { get; set; }
public string? Summary { get; set; }
public int? VolumeCount { get; set; }
public int? ChapterCount { get; set; }
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList;
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public int AverageScore { get; set; }
public int Chapters { get; set; }
public int Volumes { get; set; }
public IList<SeriesRelationship>? Relations { get; set; }
public IList<SeriesCharacter>? Characters { get; set; }
}

View File

@ -0,0 +1,22 @@
using API.Entities.Enums;
namespace API.DTOs.KavitaPlus.Metadata;
public class MetadataFieldMappingDto
{
public int Id { get; set; }
public MetadataFieldType SourceType { get; set; }
public MetadataFieldType DestinationType { get; set; }
/// <summary>
/// The string in the source
/// </summary>
public string SourceValue { get; set; }
/// <summary>
/// Write the string as this in the Destination (can also just be the Source)
/// </summary>
public string DestinationValue { get; set; }
/// <summary>
/// If true, the tag will be Moved over vs Copied over
/// </summary>
public bool ExcludeFromSource { get; set; }
}

View File

@ -0,0 +1,70 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
namespace API.DTOs.KavitaPlus.Metadata;
public class MetadataSettingsDto
{
/// <summary>
/// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Allow the Summary to be written
/// </summary>
public bool EnableSummary { get; set; }
/// <summary>
/// Allow Publication status to be derived and updated
/// </summary>
public bool EnablePublicationStatus { get; set; }
/// <summary>
/// Allow Relationships between series to be set
/// </summary>
public bool EnableRelationships { get; set; }
/// <summary>
/// Allow People to be created (including downloading images)
/// </summary>
public bool EnablePeople { get; set; }
/// <summary>
/// Allow Start date to be set within the Series
/// </summary>
public bool EnableStartDate { get; set; }
/// <summary>
/// Allow setting the Localized name
/// </summary>
public bool EnableLocalizedName { get; set; }
// Need to handle the Genre/tags stuff
public bool EnableGenres { get; set; } = true;
public bool EnableTags { get; set; } = true;
/// <summary>
/// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names.
/// </summary>
public bool FirstLastPeopleNaming { get; set; }
/// <summary>
/// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.
/// </summary>
public Dictionary<string, AgeRating> AgeRatingMappings { get; set; }
/// <summary>
/// A list of rules that allow mapping a genre/tag to another genre/tag
/// </summary>
public List<MetadataFieldMappingDto> FieldMappings { get; set; }
/// <summary>
/// Do not allow any Genre/Tag in this list to be written to Kavita
/// </summary>
public List<string> Blacklist { get; set; }
/// <summary>
/// Only allow these Tags to be written to Kavita
/// </summary>
public List<string> Whitelist { get; set; }
/// <summary>
/// Which Roles to allow metadata downloading for
/// </summary>
public List<PersonRole> PersonRoles { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace API.DTOs.KavitaPlus.Metadata;
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; }
}

View File

@ -0,0 +1,24 @@
using API.DTOs.Scrobbling;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Services.Plus;
namespace API.DTOs.KavitaPlus.Metadata;
public class ALMediaTitle
{
public string? EnglishTitle { get; set; }
public string RomajiTitle { get; set; }
public string NativeTitle { get; set; }
public string PreferredTitle { get; set; }
}
public class SeriesRelationship
{
public int AniListId { get; set; }
public int? MalId { get; set; }
public ALMediaTitle SeriesName { get; set; }
public RelationKind Relation { get; set; }
public ScrobbleProvider Provider { get; set; }
public PlusMediaFormat PlusMediaFormat { get; set; } = PlusMediaFormat.Manga;
}

View File

@ -61,4 +61,10 @@ public class LibraryDto
/// A set of globs that will exclude matching content from being scanned
/// </summary>
public ICollection<string> ExcludePatterns { get; set; }
/// <summary>
/// Allow any series within this Library to download metadata.
/// </summary>
/// <remarks>This does not exclude the library from being linked to wrt Series Relationships</remarks>
/// <remarks>Requires a valid LicenseKey</remarks>
public bool AllowMetadataMatching { get; set; } = true;
}

View File

@ -4,6 +4,8 @@
public class SeriesStaffDto
{
public required string Name { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public required string Url { get; set; }
public required string Role { get; set; }
public string? ImageUrl { get; set; }

View File

@ -1,6 +1,9 @@
namespace API.DTOs.Scrobbling;
public record PlusSeriesDto
/// <summary>
/// Represents information about a potential Series for Kavita+
/// </summary>
public record PlusSeriesRequestDto
{
public int? AniListId { get; set; }
public long? MalId { get; set; }

View File

@ -12,4 +12,5 @@ public class SeriesDetailPlusDto
public RecommendationDto? Recommendations { get; set; }
public IEnumerable<UserReviewDto> Reviews { get; set; }
public IEnumerable<RatingDto>? Ratings { get; set; }
public ExternalSeriesDetailDto? Series { get; set; }
}

View File

@ -26,6 +26,8 @@ public class UpdateLibraryDto
public bool ManageReadingLists { get; init; }
[Required]
public bool AllowScrobbling { get; init; }
[Required]
public bool AllowMetadataMatching { get; init; }
/// <summary>
/// What types of files to allow the scanner to pickup
/// </summary>

View File

@ -175,5 +175,12 @@ public class UserPreferencesDto
[Required]
public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
/// <summary>
/// Kavita+: Should this account have Scrobbling enabled for AniList
/// </summary>
public bool AniListScrobblingEnabled { get; set; }
/// <summary>
/// Kavita+: Should this account have Want to Read Sync enabled
/// </summary>
public bool WantToReadSync { get; set; }
}

View File

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using API.Entities;
@ -13,6 +15,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace API.Data;
@ -70,7 +73,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ChapterPeople> ChapterPeople { get; set; } = null!;
public DbSet<SeriesMetadataPeople> SeriesMetadataPeople { get; set; } = null!;
public DbSet<EmailHistory> EmailHistory { get; set; } = null!;
public DbSet<MetadataSettings> MetadataSettings { get; set; } = null!;
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{
@ -120,10 +124,19 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.Property(b => b.Locale)
.IsRequired(true)
.HasDefaultValue("en");
builder.Entity<AppUserPreferences>()
.Property(b => b.AniListScrobblingEnabled)
.HasDefaultValue(true);
builder.Entity<AppUserPreferences>()
.Property(b => b.WantToReadSync)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.AllowScrobbling)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.AllowMetadataMatching)
.HasDefaultValue(true);
builder.Entity<Chapter>()
.Property(b => b.WebLinks)
@ -189,6 +202,31 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.WithMany(p => p.SeriesMetadataPeople)
.HasForeignKey(smp => smp.PersonId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<MetadataSettings>()
.Property(x => x.AgeRatingMappings)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<Dictionary<string, AgeRating>>(v, JsonSerializerOptions.Default)
);
// Ensure blacklist is stored as a JSON array
builder.Entity<MetadataSettings>()
.Property(x => x.Blacklist)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)
);
// Configure one-to-many relationship
builder.Entity<MetadataSettings>()
.HasMany(x => x.FieldMappings)
.WithOne(x => x.MetadataSettings)
.HasForeignKey(x => x.MetadataSettingsId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<MetadataSettings>()
.Property(b => b.Enabled)
.HasDefaultValue(true);
}
#nullable enable

View File

@ -0,0 +1,52 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Entities.History;
using API.Entities.Metadata;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.8.5 - Migrating Kavita+ Series that are Blacklisted but have valid ExternalSeries row
/// </summary>
public static class ManualMigrateInvalidBlacklistSeries
{
public static async Task Migrate(DataContext context, ILogger<Program> logger)
{
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateInvalidBlacklistSeries"))
{
return;
}
logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Please be patient, this may take some time. This is not an error");
// Get all series in the Blacklist table and set their IsBlacklist = true
var blacklistedSeries = await context.Series
.Include(s => s.ExternalSeriesMetadata)
.Where(s => s.IsBlacklisted && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue)
.ToListAsync();
foreach (var series in blacklistedSeries)
{
series.IsBlacklisted = false;
context.Series.Entry(series).State = EntityState.Modified;
}
if (context.ChangeTracker.HasChanges())
{
await context.SaveChangesAsync();
}
await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
{
Name = "ManualMigrateInvalidBlacklistSeries",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class KavitaPlusUserAndMetadataSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowMetadataMatching",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "AniListScrobblingEnabled",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "WantToReadSync",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.CreateTable(
name: "MetadataSettings",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
EnableSummary = table.Column<bool>(type: "INTEGER", nullable: false),
EnablePublicationStatus = table.Column<bool>(type: "INTEGER", nullable: false),
EnableRelationships = table.Column<bool>(type: "INTEGER", nullable: false),
EnablePeople = table.Column<bool>(type: "INTEGER", nullable: false),
EnableStartDate = table.Column<bool>(type: "INTEGER", nullable: false),
EnableLocalizedName = table.Column<bool>(type: "INTEGER", nullable: false),
EnableGenres = table.Column<bool>(type: "INTEGER", nullable: false),
EnableTags = table.Column<bool>(type: "INTEGER", nullable: false),
FirstLastPeopleNaming = table.Column<bool>(type: "INTEGER", nullable: false),
AgeRatingMappings = table.Column<string>(type: "TEXT", nullable: true),
Blacklist = table.Column<string>(type: "TEXT", nullable: true),
Whitelist = table.Column<string>(type: "TEXT", nullable: true),
PersonRoles = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "MetadataFieldMapping",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SourceType = table.Column<int>(type: "INTEGER", nullable: false),
DestinationType = table.Column<int>(type: "INTEGER", nullable: false),
SourceValue = table.Column<string>(type: "TEXT", nullable: true),
DestinationValue = table.Column<string>(type: "TEXT", nullable: true),
ExcludeFromSource = table.Column<bool>(type: "INTEGER", nullable: false),
MetadataSettingsId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataFieldMapping", x => x.Id);
table.ForeignKey(
name: "FK_MetadataFieldMapping_MetadataSettings_MetadataSettingsId",
column: x => x.MetadataSettingsId,
principalTable: "MetadataSettings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_MetadataFieldMapping_MetadataSettingsId",
table: "MetadataFieldMapping",
column: "MetadataSettingsId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MetadataFieldMapping");
migrationBuilder.DropTable(
name: "MetadataSettings");
migrationBuilder.DropColumn(
name: "AllowMetadataMatching",
table: "Library");
migrationBuilder.DropColumn(
name: "AniListScrobblingEnabled",
table: "AppUserPreferences");
migrationBuilder.DropColumn(
name: "WantToReadSync",
table: "AppUserPreferences");
}
}
}

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.0");
modelBuilder.HasAnnotation("ProductVersion", "9.0.1");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -353,6 +353,11 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AniListScrobblingEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
@ -460,6 +465,11 @@ namespace API.Data.Migrations
b.Property<int?>("ThemeId")
.HasColumnType("INTEGER");
b.Property<bool>("WantToReadSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.HasKey("Id");
b.HasIndex("AppUserId")
@ -1093,12 +1103,37 @@ namespace API.Data.Migrations
b.ToTable("Genre");
});
modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.Property<DateTime>("RanAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ManualMigrationHistory");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowMetadataMatching")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowScrobbling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@ -1247,26 +1282,6 @@ namespace API.Data.Migrations
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.ManualMigrationHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.Property<DateTime>("RanAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ManualMigrationHistory");
});
modelBuilder.Entity("API.Entities.MediaError", b =>
{
b.Property<int>("Id")
@ -1594,6 +1609,92 @@ namespace API.Data.Migrations
b.ToTable("SeriesRelation");
});
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("DestinationType")
.HasColumnType("INTEGER");
b.Property<string>("DestinationValue")
.HasColumnType("TEXT");
b.Property<bool>("ExcludeFromSource")
.HasColumnType("INTEGER");
b.Property<int>("MetadataSettingsId")
.HasColumnType("INTEGER");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.Property<string>("SourceValue")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MetadataSettingsId");
b.ToTable("MetadataFieldMapping");
});
modelBuilder.Entity("API.Entities.MetadataSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AgeRatingMappings")
.HasColumnType("TEXT");
b.Property<string>("Blacklist")
.HasColumnType("TEXT");
b.Property<bool>("EnableGenres")
.HasColumnType("INTEGER");
b.Property<bool>("EnableLocalizedName")
.HasColumnType("INTEGER");
b.Property<bool>("EnablePeople")
.HasColumnType("INTEGER");
b.Property<bool>("EnablePublicationStatus")
.HasColumnType("INTEGER");
b.Property<bool>("EnableRelationships")
.HasColumnType("INTEGER");
b.Property<bool>("EnableStartDate")
.HasColumnType("INTEGER");
b.Property<bool>("EnableSummary")
.HasColumnType("INTEGER");
b.Property<bool>("EnableTags")
.HasColumnType("INTEGER");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("FirstLastPeopleNaming")
.HasColumnType("INTEGER");
b.PrimitiveCollection<string>("PersonRoles")
.HasColumnType("TEXT");
b.PrimitiveCollection<string>("Whitelist")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("MetadataSettings");
});
modelBuilder.Entity("API.Entities.Person", b =>
{
b.Property<int>("Id")
@ -2824,6 +2925,17 @@ namespace API.Data.Migrations
b.Navigation("TargetSeries");
});
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
{
b.HasOne("API.Entities.MetadataSettings", "MetadataSettings")
.WithMany("FieldMappings")
.HasForeignKey("MetadataSettingsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MetadataSettings");
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -3223,6 +3335,11 @@ namespace API.Data.Migrations
b.Navigation("People");
});
modelBuilder.Entity("API.Entities.MetadataSettings", b =>
{
b.Navigation("FieldMappings");
});
modelBuilder.Entity("API.Entities.Person", b =>
{
b.Navigation("ChapterPeople");

View File

@ -43,6 +43,7 @@ public interface IPersonRepository
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role);
Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames);
Task<Person?> GetPersonByAniListId(int aniListId);
}
public class PersonRepository : IPersonRepository
@ -263,6 +264,13 @@ public class PersonRepository : IPersonRepository
.ToListAsync();
}
public async Task<Person?> GetPersonByAniListId(int aniListId)
{
return await _context.Person
.Where(p => p.AniListId == aniListId)
.FirstOrDefaultAsync();
}
public async Task<IList<Person>> GetAllPeople()
{
return await _context.Person

View File

@ -79,6 +79,7 @@ public class ScrobbleRepository : IScrobbleRepository
.Include(s => s.Series)
.ThenInclude(s => s.Metadata)
.Include(s => s.AppUser)
.ThenInclude(u => u.UserPreferences)
.Where(s => s.ScrobbleEventType == type)
.Where(s => s.IsProcessed == isProcessed)
.AsSplitQuery()

View File

@ -146,7 +146,7 @@ public interface ISeriesRepository
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
int userId, SeriesIncludes includes = SeriesIncludes.None);
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats, int userId);
Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None);
public Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId,
MangaFormat format);
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
@ -164,7 +164,7 @@ public interface ISeriesRepository
Task RemoveFromOnDeck(int seriesId, int userId);
Task ClearOnDeckRemoval(int seriesId, int userId);
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None);
Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId);
Task<PlusSeriesRequestDto?> GetPlusSeriesDto(int seriesId);
Task<int> GetCountAsync();
Task<Series?> MatchSeries(ExternalSeriesDetailDto externalSeries);
}
@ -699,17 +699,16 @@ public class SeriesRepository : ISeriesRepository
var retSeries = query
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
//.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
public async Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId)
public async Task<PlusSeriesRequestDto?> GetPlusSeriesDto(int seriesId)
{
return await _context.Series
.Where(s => s.Id == seriesId)
.Select(series => new PlusSeriesDto()
.Select(series => new PlusSeriesRequestDto()
{
MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
SeriesName = series.Name,
@ -1725,24 +1724,36 @@ public class SeriesRepository : ISeriesRepository
#nullable enable
}
public async Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats, int userId)
public async Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats,
int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None)
{
var libraryIds = GetLibraryIdsForUser(userId);
var normalizedSeries = seriesName.ToNormalized();
var normalizedLocalized = localizedName.ToNormalized();
return await _context.Series
var query = _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => formats.Contains(s.Format))
.Where(s =>
.Where(s => formats.Contains(s.Format));
if (aniListId.HasValue && aniListId.Value > 0)
{
// If AniList ID is provided, override name checks
query = query.Where(s => s.ExternalSeriesMetadata.AniListId == aniListId.Value);
}
else
{
// Otherwise, use name checks
query = query.Where(s =>
s.NormalizedName.Equals(normalizedSeries)
|| s.NormalizedName.Equals(normalizedLocalized)
|| s.NormalizedLocalizedName.Equals(normalizedSeries)
|| (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized))
|| (s.OriginalName != null && s.OriginalName.Equals(seriesName))
)
);
}
return await query
.Includes(includes)
.FirstOrDefaultAsync();
}

View File

@ -1,12 +1,14 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.SeriesDetail;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
@ -14,11 +16,15 @@ namespace API.Data.Repositories;
public interface ISettingsRepository
{
void Update(ServerSetting settings);
void Update(MetadataSettings settings);
void RemoveRange(List<MetadataFieldMapping> fieldMappings);
Task<ServerSettingDto> GetSettingsDtoAsync();
Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
Task<IEnumerable<ServerSetting>> GetSettingsAsync();
void Remove(ServerSetting setting);
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
Task<MetadataSettings> GetMetadataSettings();
Task<MetadataSettingsDto> GetMetadataSettingDto();
}
public class SettingsRepository : ISettingsRepository
{
@ -36,6 +42,16 @@ public class SettingsRepository : ISettingsRepository
_context.Entry(settings).State = EntityState.Modified;
}
public void Update(MetadataSettings settings)
{
_context.Entry(settings).State = EntityState.Modified;
}
public void RemoveRange(List<MetadataFieldMapping> fieldMappings)
{
_context.MetadataFieldMapping.RemoveRange(fieldMappings);
}
public void Remove(ServerSetting setting)
{
_context.Remove(setting);
@ -48,6 +64,21 @@ public class SettingsRepository : ISettingsRepository
.FirstOrDefaultAsync();
}
public async Task<MetadataSettings> GetMetadataSettings()
{
return await _context.MetadataSettings
.Include(m => m.FieldMappings)
.FirstAsync();
}
public async Task<MetadataSettingsDto> GetMetadataSettingDto()
{
return await _context.MetadataSettings
.Include(m => m.FieldMappings)
.ProjectTo<MetadataSettingsDto>(_mapper.ConfigurationProvider)
.FirstAsync();
}
public async Task<ServerSettingDto> GetSettingsDtoAsync()
{
var settings = await _context.ServerSetting

View File

@ -262,12 +262,11 @@ public static class Seed
new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"},
new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()},
new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString()},
}.ToArray());
foreach (var defaultSetting in DefaultSettings)
{
var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key);
var existing = await context.ServerSetting.FirstOrDefaultAsync(s => s.Key == defaultSetting.Key);
if (existing == null)
{
await context.ServerSetting.AddAsync(defaultSetting);
@ -291,6 +290,35 @@ public static class Seed
}
public static async Task SeedMetadataSettings(DataContext context)
{
await context.Database.EnsureCreatedAsync();
var existing = await context.MetadataSettings.FirstOrDefaultAsync();
if (existing == null)
{
existing = new MetadataSettings()
{
Enabled = true,
EnablePeople = true,
EnableRelationships = true,
EnableSummary = true,
EnablePublicationStatus = true,
EnableStartDate = true,
EnableTags = false,
EnableGenres = true,
EnableLocalizedName = false,
FirstLastPeopleNaming = false,
PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]
};
await context.MetadataSettings.AddAsync(existing);
}
await context.SaveChangesAsync();
}
public static async Task SeedUserApiKeys(DataContext context)
{
await context.Database.EnsureCreatedAsync();

View File

@ -160,7 +160,17 @@ public class AppUserPreferences
/// UI Site Global Setting: The language locale that should be used for the user
/// </summary>
public string Locale { get; set; }
#endregion
#region KavitaPlus
/// <summary>
/// Should this account have Scrobbling enabled for AniList
/// </summary>
public bool AniListScrobblingEnabled { get; set; }
/// <summary>
/// Should this account have Want to Read Sync enabled
/// </summary>
public bool WantToReadSync { get; set; }
#endregion
public AppUser AppUser { get; set; } = null!;

View File

@ -0,0 +1,7 @@
namespace API.Entities.Enums;
public enum MetadataFieldType
{
Genre = 0,
Tag = 1,
}

View File

@ -40,8 +40,14 @@ public class Library : IEntityDate, IHasCoverImage
/// <summary>
/// Should this library allow Scrobble events to emit from it
/// </summary>
/// <remarks>Scrobbling requires a valid LicenseKey</remarks>
/// <remarks>Requires a valid LicenseKey</remarks>
public bool AllowScrobbling { get; set; } = true;
/// <summary>
/// Allow any series within this Library to download metadata.
/// </summary>
/// <remarks>This does not exclude the library from being linked to wrt Series Relationships</remarks>
/// <remarks>Requires a valid LicenseKey</remarks>
public bool AllowMetadataMatching { get; set; } = true;
public DateTime Created { get; set; }

View File

@ -0,0 +1,25 @@
using API.Entities.Enums;
namespace API.Entities;
public class MetadataFieldMapping
{
public int Id { get; set; }
public MetadataFieldType SourceType { get; set; }
public MetadataFieldType DestinationType { get; set; }
/// <summary>
/// The string in the source
/// </summary>
public string SourceValue { get; set; }
/// <summary>
/// Write the string as this in the Destination (can also just be the Source)
/// </summary>
public string DestinationValue { get; set; }
/// <summary>
/// If true, the tag will be Moved over vs Copied over
/// </summary>
public bool ExcludeFromSource { get; set; }
public int MetadataSettingsId { get; set; }
public virtual MetadataSettings MetadataSettings { get; set; }
}

View File

@ -0,0 +1,75 @@
using System.Collections.Generic;
using API.Entities.Enums;
namespace API.Entities;
/// <summary>
/// Handles the metadata settings for Kavita+
/// </summary>
public class MetadataSettings
{
public int Id { get; set; }
/// <summary>
/// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Allow the Summary to be written
/// </summary>
public bool EnableSummary { get; set; }
/// <summary>
/// Allow Publication status to be derived and updated
/// </summary>
public bool EnablePublicationStatus { get; set; }
/// <summary>
/// Allow Relationships between series to be set
/// </summary>
public bool EnableRelationships { get; set; }
/// <summary>
/// Allow People to be created (including downloading images)
/// </summary>
public bool EnablePeople { get; set; }
/// <summary>
/// Allow Start date to be set within the Series
/// </summary>
public bool EnableStartDate { get; set; }
/// <summary>
/// Allow setting the Localized name
/// </summary>
public bool EnableLocalizedName { get; set; }
// Need to handle the Genre/tags stuff
public bool EnableGenres { get; set; } = true;
public bool EnableTags { get; set; } = true;
/// <summary>
/// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names.
/// </summary>
public bool FirstLastPeopleNaming { get; set; }
/// <summary>
/// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.
/// </summary>
public Dictionary<string, AgeRating> AgeRatingMappings { get; set; }
/// <summary>
/// A list of rules that allow mapping a genre/tag to another genre/tag
/// </summary>
public List<MetadataFieldMapping> FieldMappings { get; set; }
/// <summary>
/// Do not allow any Genre/Tag in this list to be written to Kavita
/// </summary>
public List<string> Blacklist { get; set; }
/// <summary>
/// Only allow these Tags to be written to Kavita
/// </summary>
public List<string> Whitelist { get; set; }
/// <summary>
/// Which Roles to allow metadata downloading for
/// </summary>
public List<PersonRole> PersonRoles { get; set; }
}

View File

@ -12,6 +12,7 @@ using API.SignalR.Presence;
using Kavita.Common;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@ -113,6 +114,8 @@ public static class ApplicationServiceExtensions
});
options.EnableDetailedErrors();
options.EnableSensitiveDataLogging();
options.ConfigureWarnings(warnings =>
warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
});
}
}

View File

@ -33,18 +33,22 @@ public static class PlusMediaFormatExtensions
};
}
public static IList<MangaFormat> GetMangaFormats(this PlusMediaFormat? mediaFormat)
{
if (mediaFormat == null) return [MangaFormat.Archive];
return mediaFormat.HasValue ? mediaFormat.Value.GetMangaFormats() : [MangaFormat.Archive];
}
public static IList<MangaFormat> GetMangaFormats(this PlusMediaFormat mediaFormat)
{
return mediaFormat switch
{
PlusMediaFormat.Manga => [MangaFormat.Archive, MangaFormat.Image],
PlusMediaFormat.Comic => [MangaFormat.Archive],
PlusMediaFormat.LightNovel => [MangaFormat.Epub, MangaFormat.Pdf],
PlusMediaFormat.Book => [MangaFormat.Epub, MangaFormat.Pdf],
PlusMediaFormat.Unknown => [MangaFormat.Archive],
_ => [MangaFormat.Archive]
};
}
}

View File

@ -12,6 +12,7 @@ using API.DTOs.Email;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.KavitaPlus.Manage;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.MediaErrors;
using API.DTOs.Metadata;
using API.DTOs.Progress;
@ -359,5 +360,11 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.VolumeTitle, opt => opt.MapFrom(src => src.Volume.Name))
.ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId))
.ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type));
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>()));
CreateMap<MetadataFieldMapping, MetadataFieldMappingDto>();
}
}

View File

@ -104,7 +104,13 @@ public class LibraryBuilder : IEntityBuilder<Library>
return this;
}
public LibraryBuilder WIthAllowScrobbling(bool allowScrobbling)
public LibraryBuilder WithAllowMetadataMatching(bool allow)
{
_library.AllowMetadataMatching = allow;
return this;
}
public LibraryBuilder WithAllowScrobbling(bool allowScrobbling)
{
_library.AllowScrobbling = allowScrobbling;
return this;

View File

@ -7,10 +7,10 @@ using API.Services.Plus;
namespace API.Helpers.Builders;
public class PlusSeriesDtoBuilder : IEntityBuilder<PlusSeriesDto>
public class PlusSeriesDtoBuilder : IEntityBuilder<PlusSeriesRequestDto>
{
private readonly PlusSeriesDto _seriesDto;
public PlusSeriesDto Build() => _seriesDto;
private readonly PlusSeriesRequestDto _seriesRequestDto;
public PlusSeriesRequestDto Build() => _seriesRequestDto;
/// <summary>
/// This must be a FULL Series
@ -18,7 +18,7 @@ public class PlusSeriesDtoBuilder : IEntityBuilder<PlusSeriesDto>
/// <param name="series"></param>
public PlusSeriesDtoBuilder(Series series)
{
_seriesDto = new PlusSeriesDto()
_seriesRequestDto = new PlusSeriesRequestDto()
{
MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
SeriesName = series.Name,

View File

@ -73,13 +73,19 @@ public static class GenreHelper
public static void UpdateGenreList(ICollection<GenreTagDto>? existingGenres, Series series,
IReadOnlyCollection<Genre> newGenres, Action<Genre> handleAdd, Action onModified)
{
UpdateGenreList(existingGenres.DefaultIfEmpty().Select(t => t.Title).ToList(), series, newGenres, handleAdd, onModified);
}
public static void UpdateGenreList(ICollection<string>? existingGenres, Series series,
IReadOnlyCollection<Genre> newGenres, Action<Genre> handleAdd, Action onModified)
{
if (existingGenres == null) return;
var isModified = false;
// Convert tags and existing genres to hash sets for quick lookups by normalized title
var tagSet = new HashSet<string>(existingGenres.Select(t => t.Title.ToNormalized()));
var tagSet = new HashSet<string>(existingGenres.Select(t => t.ToNormalized()));
var genreSet = new HashSet<string>(series.Metadata.Genres.Select(g => g.NormalizedTitle));
// Remove tags that are no longer present in the input tags
@ -99,7 +105,7 @@ public static class GenreHelper
// Add new tags from the input list
foreach (var tagDto in existingGenres)
{
var normalizedTitle = tagDto.Title.ToNormalized();
var normalizedTitle = tagDto.ToNormalized();
if (genreSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres
@ -109,7 +115,7 @@ public static class GenreHelper
}
else
{
handleAdd(new GenreBuilder(tagDto.Title).Build()); // Add new genre if not found
handleAdd(new GenreBuilder(tagDto).Build()); // Add new genre if not found
}
isModified = true;
}

View File

@ -103,13 +103,18 @@ public static class TagHelper
public static void UpdateTagList(ICollection<TagDto>? existingDbTags, Series series, IReadOnlyCollection<Tag> newTags, Action<Tag> handleAdd, Action onModified)
{
UpdateTagList(existingDbTags.Select(t => t.Title).ToList(), series, newTags, handleAdd, onModified);
}
public static void UpdateTagList(ICollection<string>? existingDbTags, Series series, IReadOnlyCollection<Tag> newTags, Action<Tag> handleAdd, Action onModified)
{
if (existingDbTags == null) return;
var isModified = false;
// Convert tags and existing genres to hash sets for quick lookups by normalized title
var existingTagSet = new HashSet<string>(existingDbTags.Select(t => t.Title.ToNormalized()));
var existingTagSet = new HashSet<string>(existingDbTags.Select(t => t.ToNormalized()));
var dbTagSet = new HashSet<string>(series.Metadata.Tags.Select(g => g.NormalizedTitle));
// Remove tags that are no longer present in the input tags
@ -129,7 +134,7 @@ public static class TagHelper
// Add new tags from the input list
foreach (var tagDto in existingDbTags)
{
var normalizedTitle = tagDto.Title.ToNormalized();
var normalizedTitle = tagDto.ToNormalized();
if (dbTagSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres
@ -139,7 +144,7 @@ public static class TagHelper
}
else
{
handleAdd(new TagBuilder(tagDto.Title).Build()); // Add new genre if not found
handleAdd(new TagBuilder(tagDto).Build()); // Add new genre if not found
}
isModified = true;
}

View File

@ -58,7 +58,7 @@ public class Program
}
Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
? "http://localhost:5020" : "https://plus.kavitareader.com";
? "http://localhost:5020" : "https://plus-next.kavitareader.com";
try
{
@ -129,6 +129,7 @@ public class Program
await Seed.SeedDefaultStreams(unitOfWork);
await Seed.SeedDefaultSideNavStreams(unitOfWork);
await Seed.SeedUserApiKeys(context);
await Seed.SeedMetadataSettings(context);
}
catch (Exception ex)
{

View File

@ -5,17 +5,10 @@ using System.IO;
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Extensions;
using EasyCaching.Core;
using Flurl;
using Flurl.Http;
using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.Extensions.Logging;
using NetVips;
using SixLabors.ImageSharp.PixelFormats;
@ -58,6 +51,7 @@ public interface IImageService
/// <param name="encodeFormat"></param>
/// <returns></returns>
string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
/// <summary>
/// Converts the passed image to encoding and outputs it in the same directory
/// </summary>
@ -601,6 +595,7 @@ public class ImageService : IImageService
return string.Empty;
}
/// <summary>
/// Returns the name format for a chapter cover image
/// </summary>

View File

@ -1,12 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Collection;
using API.DTOs.KavitaPlus.ExternalMetadata;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Metadata.Matching;
using API.DTOs.Recommendation;
using API.DTOs.Scrobbling;
@ -16,6 +18,8 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using AutoMapper;
using Flurl.Http;
@ -33,7 +37,6 @@ public interface IExternalMetadataService
{
Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId);
Task<SeriesDetailPlusDto?> GetSeriesDetailPlus(int seriesId, LibraryType libraryType);
//Task ForceKavitaPlusRefresh(int seriesId);
Task FetchExternalDataTask();
/// <summary>
/// This is an entry point and provides a level of protection against calling upstream API. Will only allow 100 new
@ -46,7 +49,7 @@ public interface IExternalMetadataService
Task<IList<MalStackDto>> GetStacksForUser(int userId);
Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto);
Task FixSeriesMatch(int seriesId, ExternalSeriesDetailDto dto);
Task FixSeriesMatch(int seriesId, int anilistId);
Task UpdateSeriesDontMatch(int seriesId, bool dontMatch);
}
@ -58,6 +61,7 @@ public class ExternalMetadataService : IExternalMetadataService
private readonly ILicenseService _licenseService;
private readonly IScrobblingService _scrobblingService;
private readonly IEventHub _eventHub;
private readonly ICoverDbService _coverDbService;
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30);
public static readonly HashSet<LibraryType> NonEligibleLibraryTypes =
[LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine];
@ -71,7 +75,7 @@ public class ExternalMetadataService : IExternalMetadataService
private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false);
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper,
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub)
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService)
{
_unitOfWork = unitOfWork;
_logger = logger;
@ -79,6 +83,7 @@ public class ExternalMetadataService : IExternalMetadataService
_licenseService = licenseService;
_scrobblingService = scrobblingService;
_eventHub = eventHub;
_coverDbService = coverDbService;
FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl);
}
@ -120,29 +125,6 @@ public class ExternalMetadataService : IExternalMetadataService
_logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count);
}
/// <summary>
/// Removes from Blacklist and Invalidates the cache
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
// public async Task ForceKavitaPlusRefresh(int seriesId)
// {
// // TODO: I think we can remove this now
// if (!await _licenseService.HasActiveLicense()) return;
// var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId);
// if (!IsPlusEligible(libraryType)) return;
//
// // Remove from Blacklist if applicable
// var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
// series!.IsBlacklisted = false;
// _unitOfWork.SeriesRepository.Update(series);
//
// var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
// if (metadata == null) return;
//
// metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache);
// await _unitOfWork.CommitAsync();
// }
/// <summary>
/// Fetches data from Kavita+
@ -165,9 +147,7 @@ public class ExternalMetadataService : IExternalMetadataService
_logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId);
// Prefetch SeriesDetail data
await GetSeriesDetailPlus(seriesId, libraryType);
// TODO: Fetch Series Metadata (Summary, etc)
var metadata = await GetSeriesDetailPlus(seriesId, libraryType);
}
@ -266,7 +246,13 @@ public class ExternalMetadataService : IExternalMetadataService
return string.Empty; // Return as is if null, empty, or whitespace.
}
return summary.Replace("<br/>", string.Empty);
// Remove all variations of <br> tags (case-insensitive)
summary = Regex.Replace(summary, @"<br\s*/?>", " ", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Normalize whitespace (replace multiple spaces with a single space)
summary = Regex.Replace(summary, @"\s+", " ").Trim();
return summary;
}
@ -329,8 +315,8 @@ public class ExternalMetadataService : IExternalMetadataService
/// This will override any sort of matching that was done prior and force it to be what the user Selected
/// </summary>
/// <param name="seriesId"></param>
/// <param name="dto"></param>
public async Task FixSeriesMatch(int seriesId, ExternalSeriesDetailDto dto)
/// <param name="anilistId"></param>
public async Task FixSeriesMatch(int seriesId, int anilistId)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
if (series == null) return;
@ -341,14 +327,18 @@ public class ExternalMetadataService : IExternalMetadataService
_unitOfWork.SeriesRepository.Update(series);
// Refetch metadata with a Direct lookup
await FetchExternalMetadataForSeries(seriesId, series.Library.Type, new PlusSeriesDto()
var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type, new PlusSeriesRequestDto()
{
SeriesName = dto.Name,
AniListId = dto.AniListId,
MalId = dto.MALId,
MediaFormat = dto.PlusMediaFormat,
AniListId = anilistId,
SeriesName = string.Empty // Required field
});
if (metadata.Series == null)
{
_logger.LogError("Unable to Match {SeriesName} with Kavita+ Series AniList Id: {AniListId}", series.Name, anilistId);
return;
}
// Find all scrobble events and rewrite them to be the correct
var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId);
_unitOfWork.ScrobbleRepository.Remove(events);
@ -356,11 +346,12 @@ public class ExternalMetadataService : IExternalMetadataService
// Regenerate all events for the series for all users
BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistoryForSeries(seriesId));
await _eventHub.SendMessageAsync(MessageFactory.Info,
MessageFactory.InfoEvent($"Fix Match: {series.Name}", "Scrobble Events are regenerating with the new match"));
// await _eventHub.SendMessageAsync(MessageFactory.Info,
// MessageFactory.InfoEvent($"Fix Match: {series.Name}", "Scrobble Events are regenerating with the new match"));
_logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, dto.Name);
// Name can be null on Series even with a direct match
_logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, metadata.Series.Name);
}
/// <summary>
@ -398,15 +389,15 @@ public class ExternalMetadataService : IExternalMetadataService
/// <param name="libraryType"></param>
/// <param name="data"></param>
/// <returns></returns>
private async Task<SeriesDetailPlusDto> FetchExternalMetadataForSeries(int seriesId, LibraryType libraryType, PlusSeriesDto data)
private async Task<SeriesDetailPlusDto> FetchExternalMetadataForSeries(int seriesId, LibraryType libraryType, PlusSeriesRequestDto data)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
if (series == null) return _defaultReturn;
try
{
_logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", data.SeriesName);
_logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName);
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
.WithKavitaPlusHeaders(license)
@ -415,7 +406,6 @@ public class ExternalMetadataService : IExternalMetadataService
// Clear out existing results
var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series!);
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews);
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings);
@ -450,13 +440,33 @@ public class ExternalMetadataService : IExternalMetadataService
if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value;
if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value;
// If there is metadata and the user has metadata download turned on
var madeMetadataModification = false;
if (result.Series != null && series.Library.AllowMetadataMatching)
{
madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId);
if (madeMetadataModification)
{
_unitOfWork.SeriesRepository.Update(series);
}
}
await _unitOfWork.CommitAsync();
if (madeMetadataModification)
{
// Inform the UI of the update
await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(series.LibraryId, series.Id, series.Name), false);
}
return new SeriesDetailPlusDto()
{
Recommendations = recs,
Ratings = result.Ratings,
Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map<UserReviewDto>(r))
Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map<UserReviewDto>(r)),
Series = result.Series
};
}
catch (FlurlHttpException ex)
@ -478,6 +488,413 @@ public class ExternalMetadataService : IExternalMetadataService
return _defaultReturn;
}
/// <summary>
/// Given external metadata from Kavita+, write as much as possible to the Kavita series as possible
/// </summary>
/// <param name="externalMetadata"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId)
{
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)
{
series.Metadata.Summary = CleanSummary(externalMetadata.Summary);
madeModification = true;
}
if (settings.EnableStartDate && externalMetadata.StartDate.HasValue)
{
series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year;
madeModification = true;
}
var processedGenres = new List<string>();
var processedTags = new List<string>();
#region Genres and Tags
// Process Genres
if (externalMetadata.Genres != null)
{
foreach (var genre in externalMetadata.Genres.Where(g => !settings.Blacklist.Contains(g)))
{
// Apply field mappings
var mappedGenre = ApplyFieldMapping(genre, MetadataFieldType.Genre, settings.FieldMappings);
if (mappedGenre != null)
{
processedGenres.Add(mappedGenre);
}
}
// Strip blacklisted items from processedGenres
processedGenres = processedGenres.Distinct().Where(g => !settings.Blacklist.Contains(g)).ToList();
if (settings.EnableGenres && processedGenres.Count > 0)
{
_logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name);
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList();
series.Metadata.Genres ??= [];
GenreHelper.UpdateGenreList(processedGenres, series, allGenres, genre =>
{
series.Metadata.Genres.Add(genre);
madeModification = true;
}, () => series.Metadata.GenresLocked = true);
}
}
// Process Tags
if (externalMetadata.Tags != null)
{
foreach (var tag in externalMetadata.Tags.Select(t => t.Name))
{
// Apply field mappings
var mappedTag = ApplyFieldMapping(tag, MetadataFieldType.Tag, settings.FieldMappings);
if (mappedTag != null)
{
processedTags.Add(mappedTag);
}
}
// Strip blacklisted items from processedTags
processedTags = processedTags.Distinct()
.Where(g => !settings.Blacklist.Contains(g))
.Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g))
.ToList();
// Set the tags for the series and ensure they are in the DB
if (settings.EnableTags && processedTags.Count > 0)
{
_logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name);
var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize)))
.ToList();
series.Metadata.Tags ??= [];
TagHelper.UpdateTagList(processedTags, series, allTags, tag =>
{
series.Metadata.Tags.Add(tag);
madeModification = true;
}, () => series.Metadata.TagsLocked = true);
}
}
#endregion
#region Age Rating
// Determine Age Rating
var ageRating = DetermineAgeRating(processedGenres.Concat(processedTags), settings.AgeRatingMappings);
if (!series.Metadata.AgeRatingLocked && series.Metadata.AgeRating <= ageRating)
{
series.Metadata.AgeRating = ageRating;
_unitOfWork.SeriesRepository.Update(series);
madeModification = true;
}
#endregion
#region People
if (settings.EnablePeople)
{
series.Metadata.People ??= new List<SeriesMetadataPeople>();
// Ensure all people are named correctly
externalMetadata.Staff = externalMetadata.Staff.Select(s =>
{
if (settings.FirstLastPeopleNaming)
{
s.Name = s.FirstName + " " + s.LastName;
}
else
{
s.Name = s.LastName + " " + s.FirstName;
}
return s;
}).ToList();
// Roles: Character Design, Story, Art
var allWriters = externalMetadata.Staff
.Where(s => s.Role is "Story" or "Story & Art")
.ToList();
var writers = allWriters
.Select(w => new PersonDto()
{
Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = CleanSummary(w.Description),
}).ToList();
// NOTE: PersonRoles can be a hashset
if (!series.Metadata.WriterLocked && writers.Count > 0 && settings.PersonRoles.Contains(PersonRole.Writer))
{
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork);
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await DownloadAndSetCovers(allWriters);
madeModification = true;
}
var allArtists = externalMetadata.Staff
.Where(s => s.Role is "Art" or "Story & Art")
.ToList();
var artists = allArtists
.Select(w => new PersonDto()
{
Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = CleanSummary(w.Description),
}).ToList();
if (!series.Metadata.CoverArtistLocked && artists.Count > 0 && settings.PersonRoles.Contains(PersonRole.CoverArtist))
{
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork);
// Download the image and save it
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await DownloadAndSetCovers(allArtists);
madeModification = true;
}
if (externalMetadata.Characters != null && settings.PersonRoles.Contains(PersonRole.Character))
{
var characters = externalMetadata.Characters
.Select(w => new PersonDto()
{
Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
Description = CleanSummary(w.Description),
}).ToList();
if (!series.Metadata.CharacterLocked && characters.Count > 0)
{
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork);
// Download the image and save it
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
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);
}
}
madeModification = true;
}
}
}
#endregion
if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus)
{
var chapters = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes.SelectMany(v => v.Chapters).ToList();
var wasChanged = DeterminePublicationStatus(series, chapters, externalMetadata);
_unitOfWork.SeriesRepository.Update(series);
madeModification = madeModification || wasChanged;
}
if (settings.EnableRelationships && externalMetadata.Relations != null && defaultAdmin != null)
{
foreach (var relation in externalMetadata.Relations)
{
var relatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName(
relation.SeriesName.NativeTitle,
relation.SeriesName.PreferredTitle,
relation.PlusMediaFormat.GetMangaFormats(),
defaultAdmin.Id,
relation.AniListId,
SeriesIncludes.Related);
// Skip if no related series found or series is the parent
if (relatedSeries == null || relatedSeries.Id == series.Id || relation.Relation == RelationKind.Parent) continue;
// Check if the relationship already exists
var relationshipExists = series.Relations.Any(r =>
r.TargetSeriesId == relatedSeries.Id && r.RelationKind == relation.Relation);
if (relationshipExists) continue;
series.Relations.Add(new SeriesRelation
{
RelationKind = relation.Relation,
TargetSeries = relatedSeries,
TargetSeriesId = relatedSeries.Id,
Series = series,
SeriesId = series.Id
});
// Handle sequel/prequel: add reverse relationship
if (relation.Relation is RelationKind.Prequel or RelationKind.Sequel)
{
var reverseExists = relatedSeries.Relations.Any(r =>
r.TargetSeriesId == series.Id && r.RelationKind == GetReverseRelation(relation.Relation));
if (reverseExists) continue;
relatedSeries.Relations.Add(new SeriesRelation
{
RelationKind = GetReverseRelation(relation.Relation),
TargetSeries = series,
TargetSeriesId = series.Id,
Series = relatedSeries,
SeriesId = relatedSeries.Id
});
}
madeModification = true;
}
}
return madeModification;
}
private static RelationKind GetReverseRelation(RelationKind relation)
{
return relation switch
{
RelationKind.Prequel => RelationKind.Sequel,
RelationKind.Sequel => RelationKind.Prequel,
_ => relation // For other relationships, no reverse needed
};
}
private async Task DownloadAndSetCovers(List<SeriesStaffDto> people)
{
foreach (var staff in people)
{
var aniListId = ScrobblingService.ExtractId<int?>(staff.Url, ScrobblingService.AniListStaffWebsite);
if (aniListId is null or <= 0) continue;
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);
}
}
}
private bool DeterminePublicationStatus(Series series, List<Chapter> chapters, ExternalSeriesDetailDto externalMetadata)
{
var madeModification = false;
try
{
// Determine the expected total count based on local metadata
series.Metadata.TotalCount = Math.Max(
chapters.Max(chapter => chapter.TotalCount),
externalMetadata.Volumes > 0 ? externalMetadata.Volumes : externalMetadata.Chapters
);
// The actual number of count's defined across all chapter's metadata
series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count);
var nonSpecialVolumes = series.Volumes
.Where(v => v.MaxNumber.IsNot(Parser.SpecialVolumeNumber))
.ToList();
var maxVolume = (int)(nonSpecialVolumes.Count != 0 ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0);
var maxChapter = (int)chapters.Max(c => c.MaxNumber);
if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1)
{
series.Metadata.MaxCount = 1;
}
else if (series.Metadata.TotalCount <= 1 && chapters.Count == 1 && chapters[0].IsSpecial)
{
series.Metadata.MaxCount = series.Metadata.TotalCount;
}
else if ((maxChapter == Parser.DefaultChapterNumber || maxChapter > series.Metadata.TotalCount) &&
maxVolume <= series.Metadata.TotalCount)
{
series.Metadata.MaxCount = maxVolume;
}
else if (maxVolume == series.Metadata.TotalCount)
{
series.Metadata.MaxCount = maxVolume;
}
else
{
series.Metadata.MaxCount = maxChapter;
}
var status = PublicationStatus.OnGoing;
var hasExternalCounts = externalMetadata.Volumes > 0 || externalMetadata.Chapters > 0;
if (hasExternalCounts)
{
status = PublicationStatus.Ended;
// Check if all volumes/chapters match the total count
if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0)
{
status = PublicationStatus.Completed;
}
madeModification = true;
}
series.Metadata.PublicationStatus = status;
}
catch (Exception ex)
{
_logger.LogCritical(ex, "There was an issue determining Publication Status");
series.Metadata.PublicationStatus = PublicationStatus.OnGoing;
}
return madeModification;
}
private static string? ApplyFieldMapping(string value, MetadataFieldType sourceType, List<MetadataFieldMappingDto> mappings)
{
// Find matching mapping
var mapping = mappings
.FirstOrDefault(m =>
m.SourceType == sourceType &&
m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase));
if (mapping == null) return value;
// If mapping exists, return destination or source value
return mapping.DestinationValue ?? (mapping.ExcludeFromSource ? null : value);
}
private static AgeRating DetermineAgeRating(IEnumerable<string> values, Dictionary<string, AgeRating> mappings)
{
// Find highest age rating from mappings
return values
.Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown)
.DefaultIfEmpty(AgeRating.Unknown)
.Max();
}
/// <summary>
/// Gets from DB or creates a new one with just SeriesId

View File

@ -56,7 +56,7 @@ public interface IScrobblingService
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
Task ProcessUpdatesSinceLastSync();
Task CreateEventsFromExistingHistory(int userId = 0);
Task CreateEventsFromExistingHistoryForSeries(int seriesId = 0);
Task CreateEventsFromExistingHistoryForSeries(int seriesId);
Task ClearEventsForSeries(int userId, int seriesId);
}
@ -73,6 +73,9 @@ public class ScrobblingService : IScrobblingService
public const string MalWeblinkWebsite = "https://myanimelist.net/manga/";
public const string GoogleBooksWeblinkWebsite = "https://books.google.com/books?id=";
public const string MangaDexWeblinkWebsite = "https://mangadex.org/title/";
public const string AniListStaffWebsite = "https://anilist.co/staff/";
public const string AniListCharacterWebsite = "https://anilist.co/character/";
private static readonly IDictionary<string, int> WeblinkExtractionMap = new Dictionary<string, int>()
{
@ -80,6 +83,8 @@ public class ScrobblingService : IScrobblingService
{MalWeblinkWebsite, 0},
{GoogleBooksWeblinkWebsite, 0},
{MangaDexWeblinkWebsite, 0},
{AniListStaffWebsite, 0},
{AniListCharacterWebsite, 0},
};
private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90)
@ -314,6 +319,9 @@ public class ScrobblingService : IScrobblingService
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return;
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
@ -365,6 +373,9 @@ public class ScrobblingService : IScrobblingService
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return;
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
@ -419,7 +430,10 @@ public class ScrobblingService : IScrobblingService
if (!await _licenseService.HasActiveLicense()) return;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
if (series == null || !series.Library.AllowScrobbling) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return;
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
@ -639,55 +653,57 @@ public class ScrobblingService : IScrobblingService
}
}
public async Task CreateEventsFromExistingHistoryForSeries(int seriesId = 0)
public async Task CreateEventsFromExistingHistoryForSeries(int seriesId)
{
if (!await _licenseService.HasActiveLicense()) return;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
if (series == null) return;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
if (series == null || !series.Library.AllowScrobbling) return;
_logger.LogInformation("Creating Scrobbling events for Series {SeriesName}", series.Name);
var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync())
.ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling);
var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync())
.Select(u => u.Id);
foreach (var uId in userIds)
{
// Handle "Want to Read" updates specific to the series
var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId);
foreach (var wtr in wantToRead)
foreach (var wtr in wantToRead.Where(wtr => wtr.Id == seriesId))
{
if (!libAllowsScrobbling[wtr.LibraryId]) continue;
await ScrobbleWantToReadUpdate(uId, wtr.Id, true);
}
// Handle ratings specific to the series
var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(uId);
foreach (var rating in ratings)
foreach (var rating in ratings.Where(rating => rating.SeriesId == seriesId))
{
if (!libAllowsScrobbling[rating.Series.LibraryId]) continue;
await ScrobbleRatingUpdate(uId, rating.SeriesId, rating.Rating);
}
var seriesWithProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, uId,
new UserParams(), new FilterDto()
// Handle progress updates for the specific series
var seriesProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(
series.LibraryId,
uId,
new UserParams(),
new FilterDto
{
ReadStatus = new ReadStatus()
ReadStatus = new ReadStatus
{
Read = true,
InProgress = true,
NotRead = false
},
Libraries = libAllowsScrobbling.Keys.Where(k => libAllowsScrobbling[k]).ToList(),
Libraries = new List<int> { series.LibraryId },
SeriesNameQuery = series.Name
});
foreach (var seriesProgress in seriesWithProgress)
foreach (var progress in seriesProgress.Where(progress => progress.Id == seriesId))
{
if (!libAllowsScrobbling[seriesProgress.LibraryId]) continue;
if (seriesProgress.PagesRead <= 0) continue; // Since we only scrobble when things are higher, we can
await ScrobbleReadingUpdate(uId, seriesProgress.Id);
if (progress.PagesRead > 0)
{
await ScrobbleReadingUpdate(uId, progress.Id);
}
}
}
}
@ -784,6 +800,7 @@ public class ScrobblingService : IScrobblingService
.Concat(removeWantToRead.Select(r => r.AppUser))
.Concat(ratingEvents.Select(r => r.AppUser))
.Where(user => !string.IsNullOrEmpty(user.AniListAccessToken))
.Where(user => user.UserPreferences.AniListScrobblingEnabled) // TODO: Add more as we add more support
.DistinctBy(u => u.Id)
.ToList();
foreach (var user in usersToScrobble)
@ -891,8 +908,9 @@ public class ScrobblingService : IScrobblingService
{
_logger.LogDebug("Processing Reading Events: {Count} / {Total}", progressCounter, totalProgress);
progressCounter++;
// Check if this media item can even be processed for this user
if (!DoesUserHaveProviderAndValid(evt))
if (!CanProcessScrobbleEvent(evt))
{
continue;
}
@ -997,7 +1015,7 @@ public class ScrobblingService : IScrobblingService
}
private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent)
private static bool CanProcessScrobbleEvent(ScrobbleEvent readEvent)
{
var userProviders = GetUserProviders(readEvent.AppUser);
if (readEvent.Series.Library.Type == LibraryType.Manga && MangaProviders.Intersect(userProviders).Any())
@ -1052,6 +1070,12 @@ public class ScrobblingService : IScrobblingService
if (int.TryParse(value, out var intValue))
return (T)(object)intValue;
}
else if (typeof(T) == typeof(int))
{
if (int.TryParse(value, out var intValue))
return (T)(object)intValue;
return default;
}
else if (typeof(T) == typeof(long?))
{
if (long.TryParse(value, out var longValue))

View File

@ -45,8 +45,8 @@ public class WantToReadSyncService : IWantToReadSyncService
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var users = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead);
foreach (var user in users)
var users = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead | AppUserIncludes.UserPreferences);
foreach (var user in users.Where(u => u.UserPreferences.WantToReadSync))
{
if (string.IsNullOrEmpty(user.MalUserName) && string.IsNullOrEmpty(user.AniListAccessToken)) continue;

View File

@ -14,6 +14,7 @@ using API.DTOs.CollectionTags;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
@ -44,6 +45,7 @@ public interface ISeriesService
bool withHash);
Task<string> FormatChapterName(int userId, LibraryType libraryType, bool withHash = false);
Task<NextExpectedChapterDto> GetEstimatedChapterCreationDate(int seriesId, int userId);
}
public class SeriesService : ISeriesService
@ -54,6 +56,7 @@ public class SeriesService : ISeriesService
private readonly ILogger<SeriesService> _logger;
private readonly IScrobblingService _scrobblingService;
private readonly ILocalizationService _localizationService;
private readonly IImageService _imageService;
private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto
{
@ -63,7 +66,7 @@ public class SeriesService : ISeriesService
};
public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler,
ILogger<SeriesService> logger, IScrobblingService scrobblingService, ILocalizationService localizationService)
ILogger<SeriesService> logger, IScrobblingService scrobblingService, ILocalizationService localizationService, IImageService imageService)
{
_unitOfWork = unitOfWork;
_eventHub = eventHub;
@ -71,6 +74,7 @@ public class SeriesService : ISeriesService
_logger = logger;
_scrobblingService = scrobblingService;
_localizationService = localizationService;
_imageService = imageService;
}
/// <summary>
@ -206,73 +210,73 @@ public class SeriesService : ISeriesService
// Writers
if (!series.Metadata.WriterLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer, _unitOfWork);
}
// Cover Artists
if (!series.Metadata.CoverArtistLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist, _unitOfWork);
}
// Colorists
if (!series.Metadata.ColoristLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist, _unitOfWork);
}
// Editors
if (!series.Metadata.EditorLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor, _unitOfWork);
}
// Inkers
if (!series.Metadata.InkerLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker, _unitOfWork);
}
// Letterers
if (!series.Metadata.LettererLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer, _unitOfWork);
}
// Pencillers
if (!series.Metadata.PencillerLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller, _unitOfWork);
}
// Publishers
if (!series.Metadata.PublisherLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher, _unitOfWork);
}
// Imprints
if (!series.Metadata.ImprintLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint, _unitOfWork);
}
// Teams
if (!series.Metadata.TeamLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team, _unitOfWork);
}
// Locations
if (!series.Metadata.LocationLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location, _unitOfWork);
}
// Translators
if (!series.Metadata.TranslatorLocked)
{
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator);
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator, _unitOfWork);
}
}
@ -331,8 +335,10 @@ public class SeriesService : ISeriesService
/// <param name="metadata"></param>
/// <param name="peopleDtos"></param>
/// <param name="role"></param>
private async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection<PersonDto> peopleDtos, PersonRole role)
public static async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection<PersonDto> peopleDtos, PersonRole role, IUnitOfWork unitOfWork)
{
// TODO: Cleanup this code so we aren't using UnitOfWork like this
// Normalize all names from the DTOs
var normalizedNames = peopleDtos
.Select(p => Parser.Normalize(p.Name))
@ -340,7 +346,7 @@ public class SeriesService : ISeriesService
.ToList();
// Bulk select people who already exist in the database
var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
// Use a dictionary for quick lookups
var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName).ToDictionary(p => p.NormalizedName, p => p);
@ -353,13 +359,26 @@ public class SeriesService : ISeriesService
var normalizedPersonName = Parser.Normalize(personDto.Name);
// Check if the person exists in the dictionary
if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out _)) continue;
if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p))
{
if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId)
{
p.AniListId = personDto.AniListId;
}
continue; // If we ever want to update metadata for existing people, we'd do it here
}
// Person doesn't exist, so create a new one
var newPerson = new Person
{
Name = personDto.Name,
NormalizedName = normalizedPersonName
NormalizedName = normalizedPersonName,
AniListId = personDto.AniListId,
Description = personDto.Description,
Asin = personDto.Asin,
CoverImage = personDto.CoverImage,
MalId = personDto.MalId,
HardcoverId = personDto.HardcoverId,
};
peopleToAdd.Add(newPerson);
@ -369,7 +388,7 @@ public class SeriesService : ISeriesService
// Add any new people to the database in bulk
if (peopleToAdd.Count != 0)
{
_unitOfWork.PersonRepository.Attach(peopleToAdd);
unitOfWork.PersonRepository.Attach(peopleToAdd);
}
// Now that we have all the people (new and existing), update the SeriesMetadataPeople

View File

@ -4,10 +4,12 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.SignalR;
using EasyCaching.Core;
using Flurl;
using Flurl.Http;
@ -25,6 +27,8 @@ public interface ICoverDbService
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
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);
}
@ -34,6 +38,9 @@ public class CoverDbService : ICoverDbService
private readonly IDirectoryService _directoryService;
private readonly IEasyCachingProviderFactory _cacheFactory;
private readonly IHostEnvironment _env;
private readonly IImageService _imageService;
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private const string NewHost = "https://www.kavitareader.com/CoversDB/";
@ -57,12 +64,16 @@ public class CoverDbService : ICoverDbService
private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(1);
public CoverDbService(ILogger<CoverDbService> logger, IDirectoryService directoryService,
IEasyCachingProviderFactory cacheFactory, IHostEnvironment env)
IEasyCachingProviderFactory cacheFactory, IHostEnvironment env, IImageService imageService,
IUnitOfWork unitOfWork, IEventHub eventHub)
{
_logger = logger;
_directoryService = directoryService;
_cacheFactory = cacheFactory;
_env = env;
_imageService = imageService;
_unitOfWork = unitOfWork;
_eventHub = eventHub;
}
public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
@ -225,36 +236,37 @@ public class CoverDbService : ICoverDbService
{
throw new KavitaException($"Could not grab person image for {person.Name}");
}
return await DownloadPersonImageAsync(person, encodeFormat, personImageLink);
} catch (Exception ex)
{
_logger.LogError(ex, "Error downloading image for {PersonName}", person.Name);
}
// Create the destination file path
var filename = ImageService.GetPersonFormat(person.Id) + encodeFormat.GetExtension();
var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename);
return null;
}
// Ensure if file exists, we delete to overwrite
_logger.LogTrace("Fetching publisher image from {Url}", personImageLink.Sanitize());
// Download the file using Flurl
var personStream = await personImageLink
.AllowHttpStatus("2xx,304")
.GetStreamAsync();
using var image = Image.NewFromStream(personStream);
switch (encodeFormat)
/// <summary>
/// Attempts to download the Person cover image from a Url
/// </summary>
/// <param name="person"></param>
/// <param name="encodeFormat"></param>
/// <param name="url"></param>
/// <returns></returns>
/// <exception cref="KavitaException"></exception>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public async Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url)
{
try
{
var personImageLink = await GetCoverPersonImagePath(person);
if (string.IsNullOrEmpty(personImageLink))
{
case EncodeFormat.PNG:
image.Pngsave(targetFile);
break;
case EncodeFormat.WEBP:
image.Webpsave(targetFile);
break;
case EncodeFormat.AVIF:
image.Heifsave(targetFile);
break;
default:
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
throw new KavitaException($"Could not grab person image for {person.Name}");
}
var filename = await DownloadImageFromUrl(ImageService.GetPersonFormat(person.Id), encodeFormat, personImageLink);
_logger.LogDebug("Person image for {PersonName} downloaded and saved successfully", person.Name);
return filename;
@ -266,6 +278,39 @@ public class CoverDbService : ICoverDbService
return null;
}
private async Task<string> DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url)
{
// Create the destination file path
var filename = filenameWithoutExtension + encodeFormat.GetExtension();
var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename);
// Ensure if file exists, we delete to overwrite
_logger.LogTrace("Fetching person image from {Url}", url.Sanitize());
// Download the file using Flurl
var personStream = await url
.AllowHttpStatus("2xx,304")
.GetStreamAsync();
using var image = Image.NewFromStream(personStream);
switch (encodeFormat)
{
case EncodeFormat.PNG:
image.Pngsave(targetFile);
break;
case EncodeFormat.WEBP:
image.Webpsave(targetFile);
break;
case EncodeFormat.AVIF:
image.Heifsave(targetFile);
break;
default:
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
}
return filename;
}
private async Task<string> GetCoverPersonImagePath(Person person)
{
var tempFile = Path.Join(_directoryService.LongTermCacheDirectory, "people.yml");
@ -414,4 +459,49 @@ public class CoverDbService : ICoverDbService
return null;
}
public async Task SetPersonCoverImage(Person person, string url, bool fromBase64 = true)
{
if (!string.IsNullOrEmpty(url))
{
var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64);
if (!string.IsNullOrEmpty(filePath))
{
person.CoverImage = filePath;
person.CoverImageLocked = true;
_imageService.UpdateColorScape(person);
_unitOfWork.PersonRepository.Update(person);
}
}
else
{
person.CoverImage = string.Empty;
person.CoverImageLocked = false;
_imageService.UpdateColorScape(person);
_unitOfWork.PersonRepository.Update(person);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false);
}
}
private async Task<string> CreateThumbnail(string url, string filename, bool fromBase64 = true)
{
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var encodeFormat = settings.EncodeMediaAs;
var coverImageSize = settings.CoverImageSize;
if (fromBase64)
{
return _imageService.CreateThumbnailFromBase64(url,
filename, encodeFormat, coverImageSize.GetDimensions().Width);
}
return await DownloadImageFromUrl(filename, encodeFormat, url);
}
}

View File

@ -279,6 +279,7 @@ public class Startup
// v0.8.5
await ManualMigrateBlacklistTableToSeries.Migrate(dataContext, logger);
await ManualMigrateInvalidBlacklistSeries.Migrate(dataContext, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);

View File

@ -465,6 +465,7 @@
"version": "18.2.9",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz",
"integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==",
"dev": true,
"dependencies": {
"@babel/core": "7.25.2",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -492,6 +493,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"dev": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@ -506,6 +508,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
"dev": true,
"engines": {
"node": ">= 14.16.0"
},
@ -4022,7 +4025,8 @@
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
@ -4529,6 +4533,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
@ -4538,6 +4543,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@ -7479,7 +7485,8 @@
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true
},
"node_modules/replace-in-file": {
"version": "7.1.0",
@ -7750,7 +7757,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true
"dev": true
},
"node_modules/sass": {
"version": "1.77.6",
@ -7784,6 +7791,7 @@
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
@ -8338,6 +8346,7 @@
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -24,6 +24,7 @@ export interface Library {
manageCollections: boolean;
manageReadingLists: boolean;
allowScrobbling: boolean;
allowMetadataMatching: boolean;
collapseSeriesRelationships: boolean;
libraryFileTypes: Array<FileTypeGroup>;
excludePatterns: Array<string>;

View File

@ -52,6 +52,10 @@ export interface Preferences {
collapseSeriesRelationships: boolean;
shareReviews: boolean;
locale: string;
// Kavita+
aniListScrobblingEnabled: boolean;
wantToReadSync: boolean;
}
export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}];

View File

@ -242,7 +242,7 @@ export class SeriesService {
}
updateMatch(seriesId: number, series: ExternalSeriesDetail) {
return this.httpClient.post<string>(this.baseUrl + 'series/update-match?seriesId=' + seriesId, series, TextResonse);
return this.httpClient.post<string>(this.baseUrl + 'series/update-match?seriesId=' + seriesId + '&aniListId=' + series.aniListId, {}, TextResonse);
}
updateDontMatch(seriesId: number, dontMatch: boolean) {

View File

@ -1,4 +1,5 @@
<ng-container *transloco="let t; read:'match-series-result-item'">
<div class="d-flex p-1 clickable" (click)="selectItem()">
<div style="width: 32px" class="me-1">
@if (item.series.coverUrl) {
@ -11,7 +12,7 @@
@for(synm of item.series.synonyms; track synm; let last = $last) {
{{synm}}
@if (!last) {
<span>, </span>
<span>, </span>
}
}
</div>
@ -23,16 +24,27 @@
</div>
</div>
<div class="d-flex p-1 justify-content-between">
<span class="me-1"><a (click)="$event.stopPropagation()" [href]="item.series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span>
@if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span>
} @else {
<span class="me-1">{{t('releasing')}}</span>
}
@if (isSelected) {
<div class="d-flex p-1 justify-content-center">
<app-loading [absolute]="false" [loading]="true"></app-loading>
<span class="ms-2">{{t('updating-metadata-status')}}</span>
</div>
} @else {
<div class="d-flex p-1 justify-content-between">
<span class="me-1"><a (click)="$event.stopPropagation()" [href]="item.series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span>
@if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span>
} @else {
<span class="me-1">{{t('releasing')}}</span>
}
<span class="me-1">{{item.series.plusMediaFormat | plusMediaFormat}}</span>
<span class="me-1">({{item.matchRating | translocoPercent}})</span>
</div>
}
<span class="me-1">{{item.series.plusMediaFormat | plusMediaFormat}}</span>
<span class="me-1">({{item.matchRating | translocoPercent}})</span>
</div>
</ng-container>

View File

@ -15,6 +15,7 @@ import {TranslocoPercentPipe} from "@jsverse/transloco-locale";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component";
@Component({
selector: 'app-match-series-result-item',
@ -24,7 +25,8 @@ import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
TranslocoPercentPipe,
ReadMoreComponent,
TranslocoDirective,
PlusMediaFormatPipe
PlusMediaFormatPipe,
LoadingComponent
],
templateUrl: './match-series-result-item.component.html',
styleUrl: './match-series-result-item.component.scss',
@ -37,7 +39,13 @@ export class MatchSeriesResultItemComponent {
@Input({required: true}) item!: ExternalSeriesMatch;
@Output() selected: EventEmitter<ExternalSeriesMatch> = new EventEmitter();
isSelected = false;
selectItem() {
if (this.isSelected) return;
this.isSelected = true;
this.cdRef.markForCheck();
this.selected.emit(this.item);
}

View File

@ -8,6 +8,8 @@
@if (tokenExpired) {
<p class="alert alert-warning">{{t('token-expired')}}</p>
} @else if (!(accountService.currentUser$ | async)!.preferences.aniListScrobblingEnabled) {
<p class="alert alert-warning">{{t('scrobbling-disabled')}}</p>
}
<p>{{t('description')}}</p>

View File

@ -18,6 +18,8 @@ import {ToastrService} from "ngx-toastr";
import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {CardActionablesComponent} from "../card-actionables/card-actionables.component";
import {AsyncPipe} from "@angular/common";
import {AccountService} from "../../_services/account.service";
export interface DataTablePage {
pageNumber: number,
@ -29,8 +31,8 @@ export interface DataTablePage {
@Component({
selector: 'app-user-scrobble-history',
standalone: true,
imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule,
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent],
imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule,
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent, AsyncPipe],
templateUrl: './user-scrobble-history.component.html',
styleUrls: ['./user-scrobble-history.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -40,12 +42,14 @@ export class UserScrobbleHistoryComponent implements OnInit {
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber;
protected readonly ColumnMode = ColumnMode;
protected readonly ScrobbleEventType = ScrobbleEventType;
private readonly scrobblingService = inject(ScrobblingService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService);
protected readonly ScrobbleEventType = ScrobbleEventType;
protected readonly accountService = inject(AccountService);
tokenExpired = false;

View File

@ -0,0 +1,34 @@
import {AgeRating} from "../../_models/metadata/age-rating";
import {PersonRole} from "../../_models/metadata/person";
export enum MetadataFieldType {
Genre = 0,
Tag = 1
}
export interface MetadataFieldMapping {
id: number;
sourceType: MetadataFieldType;
destinationType: MetadataFieldType;
sourceValue: string;
destinationValue: string;
excludeFromSource: boolean;
}
export interface MetadataSettings {
enabled: boolean;
enableSummary: boolean;
enablePublicationStatus: boolean;
enableRelationships: boolean;
enablePeople: boolean;
enableStartDate: boolean;
enableLocalizedName: boolean;
enableGenres: boolean;
enableTags: boolean;
firstLastPeopleNaming: boolean;
ageRatingMappings: Map<string, AgeRating>;
fieldMappings: Array<MetadataFieldMapping>;
blacklist: Array<string>;
whitelist: Array<string>;
personRoles: Array<PersonRole>;
}

View File

@ -6,166 +6,165 @@
<div class="container-fluid">
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}}</p>
</div>
<form [formGroup]="formGroup">
<div class="mt-2">
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)" [isEditMode]="!isViewMode" [showEdit]="hasLicense">
<ng-template #titleExtra>
<button class="btn btn-icon btn-sm" (click)="loadLicenseInfo(true)">
@if (isChecking) {
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
} @else if (hasLicense) {
<span>
<form [formGroup]="formGroup">
<div class="mt-2">
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)" [isEditMode]="!isViewMode" [showEdit]="hasLicense">
<ng-template #titleExtra>
<button class="btn btn-icon btn-sm" (click)="loadLicenseInfo(true)">
@if (isChecking) {
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
} @else if (hasLicense) {
<span>
<i class="fa-solid fa-refresh" tabindex="0" [ngbTooltip]="t('check')"></i>
</span>
}
</button>
</ng-template>
<ng-template #view>
@if (hasLicense) {
<span class="me-1">*********</span>
}
</button>
</ng-template>
<ng-template #view>
@if (hasLicense) {
<span class="me-1">*********</span>
@if (isChecking) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
} @else {
@if (licenseInfo?.isActive) {
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">{{t('license-valid')}}</span>
</i>
@if (isChecking) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
} @else {
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')">
<span class="visually-hidden">{{t('license-not-valid')}}</span>
@if (licenseInfo?.isActive) {
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">{{t('license-valid')}}</span>
</i>
} @else {
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')">
<span class="visually-hidden">{{t('license-not-valid')}}</span>
</i>
}
}
@if (!isChecking && hasLicense && !licenseInfo) {
<div><span class="error">{{t('license-mismatch')}}</span></div>
}
} @else {
{{t('no-license-key')}}
}
</ng-template>
<ng-template #edit>
<div class="form-group mb-3">
<label for="license-key">{{t('activate-license-label')}}</label>
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="email">{{t('activate-email-label')}}</label>
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="discordId">{{t('activate-discordId-label')}}</label>
<i class="fa fa-circle-info ms-1" aria-hidden="true" [ngbTooltip]="t('activate-discordId-tooltip')"></i>
<a class="ms-1" [href]="WikiLink.KavitaPlusDiscordId" target="_blank" rel="noopener noreferrer">{{t('help-label')}}</a>
<input id="discordId" type="text" class="form-control" formControlName="discordId" autocomplete="off" [class.is-invalid]="formGroup.get('discordId')?.invalid && formGroup.get('discordId')?.touched"/>
@if (formGroup.dirty || !formGroup.untouched) {
<div id="inviteForm-validations" class="invalid-feedback">
@if (formGroup.get('discordId')?.errors?.pattern) {
<div>
{{t('discord-validation')}}
</div>
}
</div>
}
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<!-- <button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"-->
<!-- (click)="deleteLicense()">-->
<!-- {{t('activate-delete')}}-->
<!-- </button>-->
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
[ngbTooltip]="t('activate-reset-tooltip')"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="resetLicense()">
{{t('activate-reset')}}
</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
@if (!isSaving) {
<span>{{t('activate-save')}}</span>
}
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
</button>
</div>
</ng-template>
<ng-template #titleActions>
@if (hasLicense) {
@if (licenseInfo?.isActive) {
<a class="btn btn-primary-outline btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
} @else {
<a class="btn btn-primary-outline btn-sm me-1"
[ngbTooltip]="t('invalid-license-tooltip')"
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
>{{t('renew')}}</a>
}
} @else {
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
}
</ng-template>
</app-setting-item>
</div>
</form>
@if (hasLicense && licenseInfo) {
<div class="setting-section-break"></div>
<div class="row g-0 mt-3">
<h3>{{t('info-title')}}</h3>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('license-active-label')">
<ng-template #view>
@if (isChecking) {
{{null | defaultValue}}
} @else {
<i class="fas {{licenseInfo.isActive ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{licenseInfo.isActive ? t('valid') : t('invalid')}]</span>
</i>
}
}
@if (!isChecking && hasLicense && !licenseInfo) {
<div><span class="error">{{t('license-mismatch')}}</span></div>
}
</ng-template>
</app-setting-item>
</div>
} @else {
{{t('no-license-key')}}
}
</ng-template>
<ng-template #edit>
<div class="form-group mb-3">
<label for="license-key">{{t('activate-license-label')}}</label>
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="email">{{t('activate-email-label')}}</label>
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="discordId">{{t('activate-discordId-label')}}</label>
<i class="fa fa-circle-info ms-1" aria-hidden="true" [ngbTooltip]="t('activate-discordId-tooltip')"></i>
<a class="ms-1" [href]="WikiLink.KavitaPlusDiscordId" target="_blank" rel="noopener noreferrer">{{t('help-label')}}</a>
<input id="discordId" type="text" class="form-control" formControlName="discordId" autocomplete="off" [class.is-invalid]="formGroup.get('discordId')?.invalid && formGroup.get('discordId')?.touched"/>
@if (formGroup.dirty || !formGroup.untouched) {
<div id="inviteForm-validations" class="invalid-feedback">
@if (formGroup.get('discordId')?.errors?.pattern) {
<div>
{{t('discord-validation')}}
</div>
}
</div>
}
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<!-- <button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"-->
<!-- (click)="deleteLicense()">-->
<!-- {{t('activate-delete')}}-->
<!-- </button>-->
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
[ngbTooltip]="t('activate-reset-tooltip')"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="resetLicense()">
{{t('activate-reset')}}
</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
@if (!isSaving) {
<span>{{t('activate-save')}}</span>
}
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
</button>
</div>
</ng-template>
<ng-template #titleActions>
@if (hasLicense) {
@if (licenseInfo?.isActive) {
<a class="btn btn-primary-outline btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
} @else {
<a class="btn btn-primary-outline btn-sm me-1"
[ngbTooltip]="t('invalid-license-tooltip')"
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
>{{t('renew')}}</a>
}
} @else {
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
}
</ng-template>
</app-setting-item>
</div>
</form>
@if (hasLicense && licenseInfo) {
<div class="setting-section-break"></div>
<div class="row g-0 mt-3">
<h3 class="container-fluid">{{t('info-title')}}</h3>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('license-active-label')">
<ng-template #view>
@if (isChecking) {
{{null | defaultValue}}
} @else {
<i class="fas {{licenseInfo.isActive ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{licenseInfo.isActive ? t('valid') : t('invalid')}]</span>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supported-version-label')">
<ng-template #view>
<i class="fas {{licenseInfo.isValidVersion ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{isValidVersion ? t('valid') : t('invalid')}]</span>
</i>
}
</ng-template>
</app-setting-item>
</div>
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supported-version-label')">
<ng-template #view>
<i class="fas {{licenseInfo.isValidVersion ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{isValidVersion ? t('valid') : t('invalid')}]</span>
</i>
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('expiration-label')">
<ng-template #view>
{{licenseInfo.expirationDate | utcToLocalTime | defaultValue}}
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('expiration-label')">
<ng-template #view>
{{licenseInfo.expirationDate | utcToLocalTime | defaultValue}}
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('total-subbed-months-label')">
<ng-template #view>
{{licenseInfo.totalMonthsSubbed | number}}
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('total-subbed-months-label')">
<ng-template #view>
{{licenseInfo.totalMonthsSubbed | number}}
</ng-template>
</app-setting-item>
</div>
<div class="col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('email-label')">
<ng-template #view>
<div class="col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('email-label')">
<ng-template #view>
<span (click)="toggleEmailShow()" class="col-12 clickable">
@if (showEmail) {
{{licenseInfo.registeredEmail}}
@ -173,28 +172,31 @@
***************
}
</span>
</ng-template>
</app-setting-item>
</ng-template>
</app-setting-item>
</div>
<div class="setting-section-break"></div>
<!-- Actions around license -->
<h3>{{t('actions-title')}}</h3>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('delete-tooltip')">
<button type="button" class="flex-fill btn btn-danger mt-1" aria-describedby="license-key-header" (click)="deleteLicense()">
{{t('activate-delete')}}
</button>
</app-setting-button>
</div>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('manage-tooltip')">
<a class="btn btn-primary btn-sm mt-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
</app-setting-button>
</div>
</div>
}
</div>
<div class="setting-section-break"></div>
<!-- Actions around license -->
<h3 class="container-fluid">{{t('actions-title')}}</h3>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('delete-tooltip')">
<button type="button" class="flex-fill btn btn-danger mt-1" aria-describedby="license-key-header" (click)="deleteLicense()">
{{t('activate-delete')}}
</button>
</app-setting-button>
</div>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('manage-tooltip')">
<a class="btn btn-primary btn-sm mt-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
</app-setting-button>
</div>
</div>
}
</ng-container>

View File

@ -0,0 +1,266 @@
<ng-container *transloco="let t; read:'manage-metadata-settings'">
<p>{{t('description')}}</p>
@if (isLoaded) {
<form [formGroup]="settingsForm">
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enabled'); as formControl) {
<app-setting-switch [title]="t('enabled-label')" [subtitle]="t('enabled-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enabled" type="checkbox" class="form-check-input" formControlName="enabled">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableSummary'); as formControl) {
<app-setting-switch [title]="t('summary-label')" [subtitle]="t('summary-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="summary" type="checkbox" class="form-check-input" formControlName="enableSummary">
</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')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="derive-publication-status" type="checkbox" class="form-check-input" formControlName="enablePublicationStatus">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableRelationships'); as formControl) {
<app-setting-switch [title]="t('enable-relations-label')" [subtitle]="t('enable-relations-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-relations-status" type="checkbox" class="form-check-input" formControlName="enableRelationships">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableStartDate'); as formControl) {
<app-setting-switch [title]="t('enable-start-date-label')" [subtitle]="t('enable-start-date-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-start-date-status" type="checkbox" class="form-check-input" formControlName="enableStartDate">
</div>
</ng-template>
</app-setting-switch>
}
</div>
@if(settingsForm.get('enablePeople'); as formControl) {
<div class="setting-section-break"></div>
<div class="row g-0 mt-4 mb-4">
<div class="col-6">
<app-setting-switch [title]="t('enable-people-label')" [subtitle]="t('enable-people-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-people-status" type="checkbox" class="form-check-input" formControlName="enablePeople">
</div>
</ng-template>
</app-setting-switch>
</div>
<div class="col-6">
<app-setting-switch [title]="t('first-last-name-label')" [subtitle]="t('first-last-name-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-first-last-name" type="checkbox" class="form-check-input" formControlName="firstLastPeopleNaming">
</div>
</ng-template>
</app-setting-switch>
</div>
</div>
@if (settingsForm.get('personRoles')) {
<h5>{{t('person-roles-label')}}</h5>
<div class="row g-0 mt-4 mb-4" formArrayName="personRoles">
@for(role of personRoles; track role; let i = $index) {
<div class="col-md-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" [formControlName]="'personRole_' + i" [id]="'role-' + role">
<label class="form-check-label" [for]="'role-' + role">{{ role | personRole }}</label>
</div>
</div>
}
</div>
}
}
<div class="setting-section-break"></div>
<div class="row g-0 mt-4 mb-4">
<div class="col-md-6">
@if(settingsForm.get('enableGenres'); as formControl) {
<app-setting-switch [title]="t('enable-genres-label')" [subtitle]="t('enable-genres-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-genres-status" type="checkbox" class="form-check-input" formControlName="enableGenres">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="col-md-6">
@if(settingsForm.get('enableTags'); as formControl) {
<app-setting-switch [title]="t('enable-tags-label')" [subtitle]="t('enable-tags-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-tags-status" type="checkbox" class="form-check-input" formControlName="enableTags">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('blacklist'); as formControl) {
<app-setting-item [title]="t('blacklist-label')" [subtitle]="t('blacklist-tooltip')">
<ng-template #view>
@let val = (formControl.value || '').split(',');
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>s
<ng-template #edit>
<textarea rows="3" id="blacklist" class="form-control" formControlName="blacklist"></textarea>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('whitelist'); as formControl) {
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
<ng-template #view>
@let val = (formControl.value || '').split(',');
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>s
<ng-template #edit>
<textarea rows="3" id="whitelist" class="form-control" formControlName="whitelist"></textarea>
</ng-template>
</app-setting-item>
}
</div>
<div class="setting-section-break"></div>
<h4>{{t('age-rating-mapping-title')}}</h4>
<p>{{t('age-rating-mapping-description')}}</p>
<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">
<input type="text" class="form-control" formControlName="str" autocomplete="off" />
</div>
<div class="col-md-2">
<i class="fa fa-arrow-right" aria-hidden="true"></i>
</div>
<div class="col-md-4">
<select class="form-select" formControlName="rating">
@for (ageRating of ageRatings; track ageRating.value) {
<option [value]="ageRating.value">
{{ageRating.value | ageRating}}
</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-icon" (click)="removeAgeRatingMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
</div>
</div>
}
<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>
<!-- Field Mapping Table -->
<h4>{{t('field-mapping-title')}}</h4>
<p>{{t('field-mapping-description')}}</p>
<div formArrayName="fieldMappings">
@for (mapping of fieldMappings.controls; track mapping; let i = $index) {
<div [formGroupName]="i" class="row mb-2">
<div class="col-md-2">
<select class="form-select" formControlName="sourceType">
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
</select>
</div>
<div class="col-md-2">
<input type="text" class="form-control" formControlName="sourceValue"
placeholder="Source genre/tag" />
</div>
<div class="col-md-2">
<select class="form-select" formControlName="destinationType">
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
</select>
</div>
<div class="col-md-2">
<input type="text" class="form-control" formControlName="destinationValue"
placeholder="Destination genre/tag" />
</div>
<div class="col-md-2">
<div class="form-check">
<input id="remove-source-tag-{{i}}" type="checkbox" class="form-check-input"
formControlName="excludeFromSource">
<label [for]="'remove-source-tag-' + i" class="form-check-label">
{{t('remove-source-tag-label')}}
</label>
</div>
</div>
<div class="col-md-2">
<button class="btn btn-icon" (click)="removeFieldMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
</div>
</div>
}
<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>
</form>
}
</ng-container>

View File

@ -0,0 +1,219 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {SettingsService} from "../settings.service";
import {debounceTime, switchMap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {filter, map} from "rxjs/operators";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {AgeRating} from "../../_models/metadata/age-rating";
import {MetadataService} from "../../_services/metadata.service";
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-settings";
import {PersonRole} from "../../_models/metadata/person";
import {PersonRolePipe} from "../../_pipes/person-role.pipe";
import {NgClass} from "@angular/common";
@Component({
selector: 'app-manage-metadata-settings',
standalone: true,
imports: [
TranslocoDirective,
ReactiveFormsModule,
SettingSwitchComponent,
SettingItemComponent,
DefaultValuePipe,
TagBadgeComponent,
AgeRatingPipe,
PersonRolePipe,
],
templateUrl: './manage-metadata-settings.component.html',
styleUrl: './manage-metadata-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageMetadataSettingsComponent implements OnInit {
protected readonly MetadataFieldType = MetadataFieldType;
private readonly settingService = inject(SettingsService);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly fb = inject(FormBuilder);
settingsForm: FormGroup = new FormGroup({});
ageRatings: Array<AgeRatingDto> = [];
ageRatingMappings = this.fb.array([]);
fieldMappings = this.fb.array([]);
personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character];
isLoaded = false;
ngOnInit(): void {
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings = ratings;
this.cdRef.markForCheck();
});
this.settingsForm.addControl('ageRatingMappings', this.ageRatingMappings);
this.settingsForm.addControl('fieldMappings', this.fieldMappings);
this.settingService.getMetadataSettings().subscribe(settings => {
this.settingsForm.addControl('enabled', new FormControl(settings.enabled, []));
this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, []));
this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, []));
this.settingsForm.addControl('enableRelations', new FormControl(settings.enableRelationships, []));
this.settingsForm.addControl('enableGenres', new FormControl(settings.enableGenres, []));
this.settingsForm.addControl('enableTags', new FormControl(settings.enableTags, []));
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('blacklist', new FormControl((settings.blacklist || '').join(','), []));
this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), []));
this.settingsForm.addControl('firstLastPeopleNaming', new FormControl((settings.firstLastPeopleNaming), []));
this.settingsForm.addControl('personRoles', this.fb.group(
Object.fromEntries(
this.personRoles.map((role, index) => [
`personRole_${index}`,
this.fb.control((settings.personRoles || this.personRoles).includes(role)),
])
)
));
if (settings.ageRatingMappings) {
Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => {
this.addAgeRatingMapping(str, rating);
});
}
if (settings.fieldMappings) {
settings.fieldMappings.forEach(mapping => {
this.addFieldMapping(mapping);
});
}
this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => {
const firstLastControl = this.settingsForm.get('firstLastPeopleNaming');
if (enabled) {
firstLastControl?.enable();
} else {
firstLastControl?.disable();
}
});
this.settingsForm.get('enablePeople')?.updateValueAndValidity();
// Disable personRoles checkboxes based on enablePeople state
this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => {
const personRolesArray = this.settingsForm.get('personRoles') as FormArray;
if (enabled) {
personRolesArray.enable();
} else {
personRolesArray.disable();
}
});
this.isLoaded = true;
this.cdRef.markForCheck();
this.settingsForm.valueChanges.pipe(
debounceTime(300),
takeUntilDestroyed(this.destroyRef),
map(_ => this.packData()),
switchMap((data) => this.settingService.updateMetadataSettings(data)),
).subscribe();
});
}
packData(withFieldMappings: boolean = true) {
const model = this.settingsForm.value;
// Convert FormArray to dictionary
const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc, control) => {
// @ts-ignore
const { str, rating } = control.value;
if (str && rating) {
// @ts-ignore
acc[str] = parseInt(rating + '', 10) as AgeRating;
}
return acc;
}, {});
const fieldMappings = this.fieldMappings.controls.map((control) => {
const value = control.value as MetadataFieldMapping;
return {
id: value.id,
sourceType: parseInt(value.sourceType + '', 10),
destinationType: parseInt(value.destinationType + '', 10),
sourceValue: value.sourceValue,
destinationValue: value.destinationValue,
excludeFromSource: value.excludeFromSource
}
}).filter(m => m.sourceValue.length > 0);
// Translate blacklist string -> Array<string>
return {
...model,
ageRatingMappings,
fieldMappings: withFieldMappings ? fieldMappings : [],
blacklist: (model.blacklist || '').split(',').map((item: string) => item.trim()),
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)])
}
}
addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) {
const mappingGroup = this.fb.group({
str: [str, Validators.required],
rating: [rating, Validators.required]
});
// @ts-ignore
this.ageRatingMappings.push(mappingGroup);
}
removeAgeRatingMappingRow(index: number) {
this.ageRatingMappings.removeAt(index);
}
addFieldMapping(mapping: MetadataFieldMapping | null = null) {
const mappingGroup = this.fb.group({
id: [mapping?.id || 0],
sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required],
destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required],
sourceValue: [mapping?.sourceValue || '', Validators.required],
destinationValue: [mapping?.destinationValue || ''],
excludeFromSource: [mapping?.excludeFromSource || false]
});
// Autofill destination value if empty when source value loses focus
mappingGroup.get('sourceValue')?.valueChanges
.pipe(
filter(() => !mappingGroup.get('destinationValue')?.value)
)
.subscribe(sourceValue => {
mappingGroup.get('destinationValue')?.setValue(sourceValue);
});
//@ts-ignore
this.fieldMappings.push(mappingGroup);
}
removeFieldMappingRow(index: number) {
this.fieldMappings.removeAt(index);
}
}

View File

@ -4,6 +4,7 @@ import {map, of} from 'rxjs';
import { environment } from 'src/environments/environment';
import { TextResonse } from '../_types/text-response';
import { ServerSettings } from './_models/server-settings';
import {MetadataSettings} from "./_models/metadata-settings";
/**
* Used only for the Test Email Service call
@ -27,6 +28,13 @@ export class SettingsService {
return this.http.get<ServerSettings>(this.baseUrl + 'settings');
}
getMetadataSettings() {
return this.http.get<MetadataSettings>(this.baseUrl + 'settings/metadata-settings');
}
updateMetadataSettings(model: MetadataSettings) {
return this.http.post<MetadataSettings>(this.baseUrl + 'settings/metadata-settings', model);
}
updateServerSettings(model: ServerSettings) {
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
}

View File

@ -72,8 +72,8 @@ function deepClone(obj: any): any {
@Component({
selector: 'app-series-card',
standalone: true,
imports: [CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent,
EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective,
imports: [RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent,
FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective,
SeriesFormatComponent, DecimalPipe],
templateUrl: './series-card.component.html',
styleUrls: ['./series-card.component.scss'],
@ -245,6 +245,13 @@ export class SeriesCardComponent implements OnInit, OnChanges {
case(Action.Edit):
this.openEditModal(series);
break;
case Action.Match:
this.actionService.matchSeries(this.series, (refreshNeeded) => {
if (refreshNeeded) {
this.reload.emit(series.id);
}
});
break;
case(Action.AddToReadingList):
this.actionService.addSeriesToReadingList(series);
break;

View File

@ -480,7 +480,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} else if (event.event === EVENTS.ScanSeries) {
const seriesScanEvent = event.payload as ScanSeriesEvent;
if (seriesScanEvent.seriesId === this.seriesId) {
//this.loadSeries(this.seriesId);
this.loadPageSource.next(false);
}
} else if (event.event === EVENTS.CoverUpdate) {
@ -491,7 +490,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} else if (event.event === EVENTS.ChapterRemoved) {
const removedEvent = event.payload as ChapterRemovedEvent;
if (removedEvent.seriesId !== this.seriesId) return;
//this.loadSeries(this.seriesId, false);
this.loadPageSource.next(false);
}
});
@ -751,6 +749,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
.filter(action => action.action !== Action.Edit);
this.licenseService.hasValidLicense$.subscribe(hasLic => {
if (!hasLic) {
this.seriesActions = this.seriesActions.filter(action => action.action !== Action.Match);
this.cdRef.markForCheck();
}
});
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
this.relationShips = relations;

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t;">
<div class="container-fluid">
<div>
<ng-content></ng-content>
@if (subtitle) {

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t;">
<div class="container-fluid">
<div>
<div class="settings-row g-0 row">
<div class="col-10 setting-title">
<h6 class="section-title">

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t;">
<div class="container-fluid">
<div>
<div class="row g-0 mb-2">
<div class="col-11">
<h6 class="section-title" [id]="id || title">{{title}}</h6>

View File

@ -122,6 +122,14 @@
}
}
@defer (when fragment === SettingsTabId.Metadata; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.Metadata) {
<div class="scale col-md-12">
<app-manage-metadata-settings></app-manage-metadata-settings>
</div>
}
}
@defer (when fragment === SettingsTabId.ScrobblingHolds; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.ScrobblingHolds) {
<div class="scale col-md-12">

Some files were not shown because too many files have changed in this diff Show More