diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index e24cf7a86..8a8186e42 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -26,6 +26,7 @@ public abstract class AbstractDbTest : IDisposable protected readonly DbConnection _connection; protected readonly DataContext _context; protected readonly IUnitOfWork _unitOfWork; + protected readonly IMapper _mapper; protected const string CacheDirectory = "C:/kavita/config/cache/"; @@ -42,6 +43,7 @@ public abstract class AbstractDbTest : IDisposable { var contextOptions = new DbContextOptionsBuilder() .UseSqlite(CreateInMemoryDatabase()) + .EnableSensitiveDataLogging() .Options; _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; @@ -53,10 +55,10 @@ public abstract class AbstractDbTest : IDisposable Task.Run(SeedDb).GetAwaiter().GetResult(); var config = new MapperConfiguration(cfg => cfg.AddProfile()); - var mapper = config.CreateMapper(); + _mapper = config.CreateMapper(); GlobalConfiguration.Configuration.UseInMemoryStorage(); - _unitOfWork = new UnitOfWork(_context, mapper, null); + _unitOfWork = new UnitOfWork(_context, _mapper, null); } private static DbConnection CreateInMemoryDatabase() @@ -92,6 +94,7 @@ public abstract class AbstractDbTest : IDisposable _context.Library.Add(new LibraryBuilder("Manga") + .WithAllowMetadataMatching(true) .WithFolderPath(new FolderPathBuilder(DataDirectory).Build()) .Build()); diff --git a/API.Tests/Extensions/SeriesFilterTests.cs b/API.Tests/Extensions/SeriesFilterTests.cs index 8041c9930..372ddb78c 100644 --- a/API.Tests/Extensions/SeriesFilterTests.cs +++ b/API.Tests/Extensions/SeriesFilterTests.cs @@ -932,11 +932,11 @@ public class SeriesFilterTests : AbstractDbTest var seriesService = new SeriesService(_unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For(), Substitute.For() - , Substitute.For()); + Substitute.For(), Substitute.For()); // Select 0 Rating var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + Assert.NotNull(zeroRating); Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto() { diff --git a/API.Tests/Helpers/StringHelperTests.cs b/API.Tests/Helpers/StringHelperTests.cs new file mode 100644 index 000000000..76b089069 --- /dev/null +++ b/API.Tests/Helpers/StringHelperTests.cs @@ -0,0 +1,18 @@ +using System; +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class StringHelperTests +{ + [Theory] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!



Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

", + "

A Perfect Marriage Becomes a Perfect Affair!
Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

" + )] + public void Test(string input, string expected) + { + Assert.Equal(expected, StringHelper.SquashBreaklines(input)); + } +} diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs new file mode 100644 index 000000000..a47282489 --- /dev/null +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -0,0 +1,2798 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; +using API.DTOs.Scrobbling; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers.Builders; +using API.Services.Plus; +using API.Services.Tasks.Metadata; +using API.SignalR; +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; +using YamlDotNet.Serialization; + +namespace API.Tests.Services; + +/// +/// Given these rely on Kavita+, this will not have any [Fact]/[Theory] on them and must be manually checked +/// +public class ExternalMetadataServiceTests : AbstractDbTest +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly ExternalMetadataService _externalMetadataService; + private readonly Dictionary _genreLookup = new Dictionary(); + private readonly Dictionary _tagLookup = new Dictionary(); + private readonly Dictionary _personLookup = new Dictionary(); + + + public ExternalMetadataServiceTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + + // Set up Hangfire to use in-memory storage for testing + GlobalConfiguration.Configuration.UseInMemoryStorage(); + + _externalMetadataService = new ExternalMetadataService(_unitOfWork, Substitute.For>(), + _mapper, Substitute.For(), Substitute.For(), Substitute.For(), + Substitute.For()); + } + + #region Gloabl + + [Fact] + public async Task Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = false; + metadataSettings.EnableSummary = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.Metadata.Summary); + } + + #endregion + + #region Summary + + [Fact] + public async Task Summary_NoExisting_Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal(series.Metadata.Summary, postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked") + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should not write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This summary is not locked", postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked", true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should not write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This summary is not locked", postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked", true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + metadataSettings.Overrides = [MetadataSettingField.Summary]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This should write", postSeries.Metadata.Summary); + } + + + #endregion + + #region Release Year + + [Fact] + public async Task ReleaseYear_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(0, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(DateTime.UtcNow.Year, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(1990, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990, true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(1990, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990, true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + metadataSettings.Overrides = [MetadataSettingField.StartDate]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(DateTime.UtcNow.Year, postSeries.Metadata.ReleaseYear); + } + + #endregion + + #region LocalizedName + + [Fact] + public async Task LocalizedName_NoExisting_Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Kimchi", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here") + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Localized Name here", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here", true) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Localized Name here", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here", true) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + metadataSettings.Overrides = [MetadataSettingField.LocalizedName]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Kimchi", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_OnlyNonEnglishSynonyms_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.True(string.IsNullOrEmpty(postSeries.LocalizedName)); + } + + #endregion + + #region Publication Status + + [Fact] + public async Task PublicationStatus_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.OnGoing, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus, true) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Hiatus, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus, true) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + metadataSettings.Overrides = [MetadataSettingField.PublicationStatus]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_CorrectState_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Ended, postSeries.Metadata.PublicationStatus); + } + + + + #endregion + + #region Age Rating + + [Fact] + public async Task AgeRating_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_ExistingHigher_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Mature) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Mature, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_ExistingLower_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone, true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Everyone, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone, true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.Overrides = [MetadataSettingField.AgeRating]; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + #endregion + + #region Genres + + [Fact] + public async Task Genres_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.Genres); + } + + [Fact] + public async Task Genres_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"]) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"], true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Action"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"], true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + #endregion + + #region Tags + + [Fact] + public async Task Tags_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.Tags); + } + + [Fact] + public async Task Tags_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task Tags_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithTag(_tagLookup["H"], true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["H"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task Tags_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithTag(_tagLookup["H"], true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Tags]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + #endregion + + #region People - Writers/Artists + + [Fact] + public async Task People_Writer_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer)); + } + + [Fact] + public async Task People_Writer_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["John Doe"], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Person.Name)); + } + + [Fact] + public async Task People_Writer_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Writer_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe", "Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + Assert.True( postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .FirstOrDefault(p => p.Person.Name == "John Doe")!.KavitaPlusConnection); + } + + [Fact] + public async Task People_Writer_Locked_Override_ReverseNamingMatch_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = false; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Twowheeler", "Johnny", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Writer_Locked_Override_PersonRoleNotSet_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = []; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + + [Fact] + public async Task People_Writer_OverrideReMatchDeletesOld_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe 2", "Story")] + }, 1); + + postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe 2"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + #endregion + + #region People - Characters + + [Fact] + public async Task People_Character_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character)); + } + + [Fact] + public async Task People_Character_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["John Doe"], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Person.Name)); + } + + [Fact] + public async Task People_Character_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.CharacterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Character_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe", "Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + Assert.True( postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .FirstOrDefault(p => p.Person.Name == "John Doe")!.KavitaPlusConnection); + } + + [Fact] + public async Task People_Character_Locked_Override_ReverseNamingNoMatch_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = false; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("Twowheeler", "Johnny", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler", "Twowheeler Johnny"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Character_Locked_Override_PersonRoleNotSet_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = []; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + + [Fact] + public async Task People_Character_OverrideReMatchDeletesOld_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe 2", CharacterRole.Main)] + }, 1); + + postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe 2"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + #endregion + + #region Series Cover + // Not sure how to test this + #endregion + + #region Relationships + + // Not enabled + + // Non-Sequel + + [Fact] + public async Task Relationships_NonSequel() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithExternalMetadata(new ExternalSeriesMetadata() + { + AniListId = 10 + }) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + } + + [Fact] + public async Task Relationships_NonSequel_LocalizedName() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithLocalizedName("School bus") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = "School bus", + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + } + + // Non-Sequel with no match due to Format difference + [Fact] + public async Task Relationships_NonSequel_FormatDifference() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithLocalizedName("School bus") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = "School bus", + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Book + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Empty(sourceSeries.Relations); + } + + // Non-Sequel existing relationship with new link, both exist + [Fact] + public async Task Relationships_NonSequel_ExistingLink_DifferentType_BothExist() + { + await ResetDb(); + + var existingRelationshipSeries = new SeriesBuilder("Existing") + .WithLibraryId(1) + .Build(); + _context.Series.Attach(existingRelationshipSeries); + await _context.SaveChangesAsync(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithRelationship(existingRelationshipSeries.Id, RelationKind.Annual) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithExternalMetadata(new ExternalSeriesMetadata() + { + AniListId = 10 + }) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 2); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Equal(seriesName, sourceSeries.Name); + + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.Annual && r.TargetSeriesId == existingRelationshipSeries.Id); + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.SideStory && r.TargetSeriesId == series2.Id); + } + + + + // Sequel/Prequel + [Fact] + public async Task Relationships_Sequel_CreatesPrequel() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Source"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.Sequel, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + + var sequel = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sequel); + Assert.Equal(seriesName, sequel.Relations.First().TargetSeries.Name); + } + + + #endregion + + #region Blacklist + + [Fact] + public async Task Blacklist_Genres() + { + await ResetDb(); + + const string seriesName = "Test - Blacklist Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.EnableGenres = true; + metadataSettings.Blacklist = ["Sports", "Action"]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Boxing", "Sports", "Action"], + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Boxing"}.OrderBy(s => s), postSeries.Metadata.Genres.Select(t => t.Title).OrderBy(s => s)); + } + + + [Fact] + public async Task Blacklist_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Blacklist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.EnableGenres = true; + metadataSettings.Blacklist = ["Sports", "Action"]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Sports"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Boxing"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + // Blacklist Tag + + // Field Map then Blacklist Genre + + // Field Map then Blacklist Tag + + #endregion + + #region Whitelist + + [Fact] + public async Task Whitelist_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Whitelist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Whitelist = ["Sports", "Action"]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Sports"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Sports", "Action"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + [Fact] + public async Task Whitelist_WithFieldMap_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Whitelist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Boxing", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Sports", + ExcludeFromSource = false + + }]; + metadataSettings.Whitelist = ["Sports", "Action"]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Sports", "Action"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + #endregion + + #region Field Mapping + + [Fact] + public async Task FieldMap_GenreToGenre_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] { "Ecchi", "Fanservice" }.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + } + + [Fact] + public async Task FieldMap_GenreToGenre_RemoveSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Fanservice", + ExcludeFromSource = true + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Fanservice"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task FieldMap_TagToTag_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tag Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Ecchi"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] { "Ecchi", "Fanservice" }.OrderBy(s => s), + postSeries.Metadata.Tags.Select(g => g.Title).OrderBy(s => s) + ); + } + + [Fact] + public async Task Tags_Existing_FieldMap_RemoveSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tag Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = true + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Ecchi"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Fanservice"], postSeries.Metadata.Tags.Select(g => g.Title)); + } + + [Fact] + public async Task FieldMap_GenreToTag_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres, MetadataSettingField.Tags]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] {"Ecchi"}.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + Assert.Equal( + new[] {"Fanservice"}.OrderBy(s => s), + postSeries.Metadata.Tags.Select(g => g.Title).OrderBy(s => s) + ); + } + + + + [Fact] + public async Task FieldMap_GenreToGenre_RemoveSource_NoExternalGenre_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"]) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres, MetadataSettingField.Tags]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Action", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Adventure", + ExcludeFromSource = true + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] {"Action"}.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + } + + #endregion + + + + protected override async Task ResetDb() + { + _context.Series.RemoveRange(_context.Series); + _context.AppUser.RemoveRange(_context.AppUser); + _context.Genre.RemoveRange(_context.Genre); + _context.Tag.RemoveRange(_context.Tag); + _context.Person.RemoveRange(_context.Person); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = false; + metadataSettings.EnableSummary = false; + metadataSettings.EnableCoverImage = false; + metadataSettings.EnableLocalizedName = false; + metadataSettings.EnableGenres = false; + metadataSettings.EnablePeople = false; + metadataSettings.EnableRelationships = false; + metadataSettings.EnableTags = false; + metadataSettings.EnablePublicationStatus = false; + metadataSettings.EnableStartDate = false; + _context.MetadataSettings.Update(metadataSettings); + + await _context.SaveChangesAsync(); + + _context.AppUser.Add(new AppUserBuilder("Joe", "Joe") + .WithRole(PolicyConstants.AdminRole) + .WithLibrary(await _context.Library.FirstAsync(l => l.Id == 1)) + .Build()); + + // Create a bunch of Genres for this test and store their string in _genreLookup + _genreLookup.Clear(); + var g1 = new GenreBuilder("Action").Build(); + var g2 = new GenreBuilder("Ecchi").Build(); + _context.Genre.Add(g1); + _context.Genre.Add(g2); + _genreLookup.Add("Action", g1); + _genreLookup.Add("Ecchi", g2); + + _tagLookup.Clear(); + var t1 = new TagBuilder("H").Build(); + var t2 = new TagBuilder("Boxing").Build(); + _context.Tag.Add(t1); + _context.Tag.Add(t2); + _tagLookup.Add("H", t1); + _tagLookup.Add("Boxing", t2); + + _personLookup.Clear(); + var p1 = new PersonBuilder("Johnny Twowheeler").Build(); + var p2 = new PersonBuilder("Boxing").Build(); + _context.Person.Add(p1); + _context.Person.Add(p2); + _personLookup.Add("Johnny Twowheeler", p1); + _personLookup.Add("Batman Robin", p2); + + await _context.SaveChangesAsync(); + } + + private static SeriesStaffDto CreateStaff(string first, string last, string role) + { + return new SeriesStaffDto() {Name = $"{first} {last}", Role = role, Url = "", FirstName = first, LastName = last}; + } + + private static SeriesCharacter CreateCharacter(string first, string last, CharacterRole role) + { + return new SeriesCharacter() {Name = $"{first} {last}", Description = "", Url = "", ImageUrl = "", Role = role}; + } +} diff --git a/API.Tests/Services/ProcessSeriesTests.cs b/API.Tests/Services/ProcessSeriesTests.cs index ef5c45007..0fbe5db12 100644 --- a/API.Tests/Services/ProcessSeriesTests.cs +++ b/API.Tests/Services/ProcessSeriesTests.cs @@ -17,7 +17,7 @@ namespace API.Tests.Services; public class ProcessSeriesTests { - + // TODO: Implement #region UpdateSeriesMetadata diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index cf92ea6ec..385b63f51 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -56,7 +56,7 @@ public class SeriesServiceTests : AbstractDbTest _seriesService = new SeriesService(_unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For(), locService, Substitute.For()); + Substitute.For(), locService); } #region Setup diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs index 0584f7319..8ac3d434a 100644 --- a/API/Controllers/LicenseController.cs +++ b/API/Controllers/LicenseController.cs @@ -64,7 +64,14 @@ public class LicenseController( [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] public async Task> GetLicenseInfo(bool forceCheck = false) { - return Ok(await licenseService.GetLicenseInfo(forceCheck)); + try + { + return Ok(await licenseService.GetLicenseInfo(forceCheck)); + } + catch (Exception) + { + return Ok(null); + } } [Authorize("RequireAdminRole")] diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs index 56c2e0274..a5a037cc3 100644 --- a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs @@ -15,7 +15,7 @@ public class ExternalSeriesDetailDto public string Name { get; set; } public int? AniListId { get; set; } public long? MALId { get; set; } - public IList Synonyms { get; set; } + public IList Synonyms { get; set; } = []; public PlusMediaFormat PlusMediaFormat { get; set; } public string? SiteUrl { get; set; } public string? CoverUrl { get; set; } @@ -30,8 +30,8 @@ public class ExternalSeriesDetailDto public int AverageScore { get; set; } public int Chapters { get; set; } public int Volumes { get; set; } - public IList? Relations { get; set; } - public IList? Characters { get; set; } + public IList? Relations { get; set; } = []; + public IList? Characters { get; set; } = []; } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index aaab361db..a100a5046 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -230,9 +230,10 @@ public class PersonRepository : IPersonRepository public async Task> GetSeriesKnownFor(int personId) { + List notValidRoles = [PersonRole.Location, PersonRole.Team, PersonRole.Other, PersonRole.Publisher, PersonRole.Translator]; return await _context.Person .Where(p => p.Id == personId) - .SelectMany(p => p.SeriesMetadataPeople) + .SelectMany(p => p.SeriesMetadataPeople.Where(smp => !notValidRoles.Contains(smp.Role))) .Select(smp => smp.SeriesMetadata) .Select(sm => sm.Series) .Distinct() diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 479b5cd19..1eb613ea4 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -75,6 +75,7 @@ public interface ISeriesRepository { void Add(Series series); void Attach(Series series); + void Attach(SeriesRelation relation); void Update(Series series); void Remove(Series series); void Remove(IEnumerable series); @@ -146,6 +147,9 @@ public interface ISeriesRepository Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); + + Task GetSeriesByAnyName(IList names, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None); Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None); public Task> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format); @@ -195,6 +199,11 @@ public class SeriesRepository : ISeriesRepository _context.Series.Attach(series); } + public void Attach(SeriesRelation relation) + { + _context.SeriesRelation.Attach(relation); + } + public void Attach(ExternalSeriesMetadata metadata) { _context.ExternalSeriesMetadata.Attach(metadata); @@ -1757,6 +1766,41 @@ public class SeriesRepository : ISeriesRepository .FirstOrDefaultAsync(); } + + public async Task GetSeriesByAnyName(IList names, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) + { + var libraryIds = GetLibraryIdsForUser(userId); + names = names.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); + var normalizedNames = names.Select(s => s.ToNormalized()).ToList(); + + + var query = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .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 || + normalizedNames.Contains(s.NormalizedName) + || normalizedNames.Contains(s.NormalizedLocalizedName) + || names.Contains(s.OriginalName)); + } + else + { + // Otherwise, use name checks + query = query.Where(s => + normalizedNames.Contains(s.NormalizedName) + || normalizedNames.Contains(s.NormalizedLocalizedName) + || names.Contains(s.OriginalName)); + } + + return await query + .Includes(includes) + .FirstOrDefaultAsync(); + } + public async Task> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format) { diff --git a/API/Extensions/PlusMediaFormatExtensions.cs b/API/Extensions/PlusMediaFormatExtensions.cs index bb2b8c426..a88b9c2f9 100644 --- a/API/Extensions/PlusMediaFormatExtensions.cs +++ b/API/Extensions/PlusMediaFormatExtensions.cs @@ -26,9 +26,10 @@ public static class PlusMediaFormatExtensions { return plusMediaFormat switch { - PlusMediaFormat.Manga => new[] { LibraryType.Manga, LibraryType.Image }, - PlusMediaFormat.Comic => new[] { LibraryType.Comic, LibraryType.ComicVine }, - PlusMediaFormat.LightNovel => new[] { LibraryType.LightNovel, LibraryType.Book, LibraryType.Manga }, + PlusMediaFormat.Manga => [LibraryType.Manga, LibraryType.Image], + PlusMediaFormat.Comic => [LibraryType.Comic, LibraryType.ComicVine], + PlusMediaFormat.LightNovel => [LibraryType.LightNovel, LibraryType.Book, LibraryType.Manga], + PlusMediaFormat.Book => [LibraryType.LightNovel, LibraryType.Book], _ => throw new ArgumentOutOfRangeException(nameof(plusMediaFormat), plusMediaFormat, null) }; } diff --git a/API/Helpers/Builders/AppUserBuilder.cs b/API/Helpers/Builders/AppUserBuilder.cs index bc044c301..282361e41 100644 --- a/API/Helpers/Builders/AppUserBuilder.cs +++ b/API/Helpers/Builders/AppUserBuilder.cs @@ -61,4 +61,10 @@ public class AppUserBuilder : IEntityBuilder return this; } + public AppUserBuilder WithRole(string role) + { + _appUser.UserRoles ??= new List(); + _appUser.UserRoles.Add(new AppUserRole() {Role = new AppRole() {Name = role}}); + return this; + } } diff --git a/API/Helpers/Builders/SeriesBuilder.cs b/API/Helpers/Builders/SeriesBuilder.cs index 525b0cddc..96e820659 100644 --- a/API/Helpers/Builders/SeriesBuilder.cs +++ b/API/Helpers/Builders/SeriesBuilder.cs @@ -21,11 +21,13 @@ public class SeriesBuilder : IEntityBuilder _series = new Series() { Name = name, + LocalizedName = name.ToNormalized(), + NormalizedLocalizedName = name.ToNormalized(), + OriginalName = name, SortName = name, NormalizedName = name.ToNormalized(), - NormalizedLocalizedName = name.ToNormalized(), Metadata = new SeriesMetadataBuilder() .WithPublicationStatus(PublicationStatus.OnGoing) .Build(), @@ -39,14 +41,25 @@ public class SeriesBuilder : IEntityBuilder /// /// /// - public SeriesBuilder WithLocalizedName(string localizedName) + public SeriesBuilder WithLocalizedName(string localizedName, bool lockStatus = false) { + // Why is this here? if (string.IsNullOrEmpty(localizedName)) { localizedName = _series.Name; } + _series.LocalizedName = localizedName; _series.NormalizedLocalizedName = localizedName.ToNormalized(); + _series.LocalizedNameLocked = lockStatus; + return this; + } + + public SeriesBuilder WithLocalizedNameAllowEmpty(string localizedName, bool lockStatus = false) + { + _series.LocalizedName = localizedName; + _series.NormalizedLocalizedName = localizedName.ToNormalized(); + _series.LocalizedNameLocked = lockStatus; return this; } @@ -106,4 +119,15 @@ public class SeriesBuilder : IEntityBuilder } + public SeriesBuilder WithRelationship(int targetSeriesId, RelationKind kind) + { + _series.Relations ??= []; + _series.Relations.Add(new SeriesRelation() + { + RelationKind = kind, + TargetSeriesId = targetSeriesId + }); + + return this; + } } diff --git a/API/Helpers/Builders/SeriesMetadataBuilder.cs b/API/Helpers/Builders/SeriesMetadataBuilder.cs index 316eaaf83..b94e3e4c3 100644 --- a/API/Helpers/Builders/SeriesMetadataBuilder.cs +++ b/API/Helpers/Builders/SeriesMetadataBuilder.cs @@ -39,15 +39,17 @@ public class SeriesMetadataBuilder : IEntityBuilder return this; } - public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status) + public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status, bool lockState = false) { _seriesMetadata.PublicationStatus = status; + _seriesMetadata.PublicationStatusLocked = lockState; return this; } - public SeriesMetadataBuilder WithAgeRating(AgeRating rating) + public SeriesMetadataBuilder WithAgeRating(AgeRating rating, bool lockState = false) { _seriesMetadata.AgeRating = rating; + _seriesMetadata.AgeRatingLocked = lockState; return this; } @@ -60,7 +62,6 @@ public class SeriesMetadataBuilder : IEntityBuilder Person = person, SeriesMetadata = _seriesMetadata, }); - return this; } @@ -70,15 +71,40 @@ public class SeriesMetadataBuilder : IEntityBuilder return this; } - public SeriesMetadataBuilder WithReleaseYear(int year) + public SeriesMetadataBuilder WithReleaseYear(int year, bool lockStatus = false) { _seriesMetadata.ReleaseYear = year; + _seriesMetadata.ReleaseYearLocked = lockStatus; return this; } - public SeriesMetadataBuilder WithSummary(string summary) + public SeriesMetadataBuilder WithSummary(string summary, bool lockStatus = false) { _seriesMetadata.Summary = summary; + _seriesMetadata.SummaryLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithGenre(Genre genre, bool lockStatus = false) + { + _seriesMetadata.Genres ??= []; + _seriesMetadata.Genres.Add(genre); + _seriesMetadata.GenresLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithGenres(List genres, bool lockStatus = false) + { + _seriesMetadata.Genres = genres; + _seriesMetadata.GenresLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithTag(Tag tag, bool lockStatus = false) + { + _seriesMetadata.Tags ??= []; + _seriesMetadata.Tags.Add(tag); + _seriesMetadata.TagsLocked = lockStatus; return this; } } diff --git a/API/Helpers/StringHelper.cs b/API/Helpers/StringHelper.cs new file mode 100644 index 000000000..4f3fa44f6 --- /dev/null +++ b/API/Helpers/StringHelper.cs @@ -0,0 +1,42 @@ +using System.Text.RegularExpressions; + +namespace API.Helpers; +#nullable enable + +public static class StringHelper +{ + /// + /// Used to squash duplicate break and new lines with a single new line. + /// + /// Test br br Test -> Test br Test + /// + /// + public static string? SquashBreaklines(string? summary) + { + if (string.IsNullOrWhiteSpace(summary)) + { + return null; + } + + // First standardize all br tags to
format + summary = Regex.Replace(summary, @"", "
", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // Replace multiple consecutive br tags with a single br tag + summary = Regex.Replace(summary, @"(?:
\s*)+", "
", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // Normalize remaining whitespace (replace multiple spaces with a single space) + summary = Regex.Replace(summary, @"\s+", " ").Trim(); + + return summary.Trim(); + } + + /// + /// Removes the (Source: MangaDex) type of tags at the end of descriptions from AL + /// + /// + /// + public static string? RemoveSourceInDescription(string? description) + { + return description?.Trim(); + } +} diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 8705fc028..3255d93df 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -51,6 +51,7 @@ public interface IExternalMetadataService Task> MatchSeries(MatchSeriesDto dto); Task FixSeriesMatch(int seriesId, int anilistId); Task UpdateSeriesDontMatch(int seriesId, bool dontMatch); + Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId); } public class ExternalMetadataService : IExternalMetadataService @@ -229,7 +230,7 @@ public class ExternalMetadataService : IExternalMetadataService // Some summaries can contain multiple
s, we need to ensure it's only 1 foreach (var result in results) { - result.Series.Summary = CleanSummary(result.Series.Summary); + result.Series.Summary = StringHelper.SquashBreaklines(result.Series.Summary); } return results; @@ -242,23 +243,6 @@ public class ExternalMetadataService : IExternalMetadataService return ArraySegment.Empty; } - private static string CleanSummary(string? summary) - { - if (string.IsNullOrWhiteSpace(summary)) - { - return string.Empty; // Return as is if null, empty, or whitespace. - } - - // Remove all variations of
tags (case-insensitive) - summary = Regex.Replace(summary, @"", " ", RegexOptions.IgnoreCase | RegexOptions.Compiled); - - // Normalize whitespace (replace multiple spaces with a single space) - summary = Regex.Replace(summary, @"\s+", " ").Trim(); - - return summary; - } - - /// /// Retrieves Metadata about a Recommended External Series @@ -349,9 +333,6 @@ 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")); - // Name can be null on Series even with a direct match _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, metadata.Series.Name); @@ -373,7 +354,7 @@ public class ExternalMetadataService : IExternalMetadataService if (dontMatch) { // When we set as DontMatch, we will clear existing External Metadata - var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series!); + var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series); _unitOfWork.ExternalSeriesMetadataRepository.Remove(series.ExternalSeriesMetadata); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings); @@ -409,7 +390,7 @@ public class ExternalMetadataService : IExternalMetadataService // Clear out existing results - var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series!); + var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations); @@ -457,8 +438,11 @@ public class ExternalMetadataService : IExternalMetadataService } } - - await _unitOfWork.CommitAsync(); + // WriteExternalMetadataToSeries will commit but not always + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } if (madeMetadataModification) { @@ -499,7 +483,7 @@ public class ExternalMetadataService : IExternalMetadataService /// /// /// - private async Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId) + public async Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId) { var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); if (!settings.Enabled) return false; @@ -512,411 +496,567 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogInformation("Writing External metadata to Series {SeriesName}", series.Name); var madeModification = false; - - if (settings.EnableLocalizedName && (settings.HasOverride(MetadataSettingField.LocalizedName) - || !series.LocalizedNameLocked && !string.IsNullOrWhiteSpace(series.LocalizedName))) - { - // We need to make the best appropriate guess - if (externalMetadata.Name == series.Name) - { - // Choose closest (usually last) synonym - var validSynonyms = externalMetadata.Synonyms - .Where(IsRomanCharacters) - .Where(s => s.ToNormalized() != series.Name.ToNormalized()) - .ToList(); - if (validSynonyms.Count != 0) - { - series.LocalizedName = validSynonyms[^1]; - series.LocalizedNameLocked = true; - } - } - else if (IsRomanCharacters(externalMetadata.Name)) - { - series.LocalizedName = externalMetadata.Name; - series.LocalizedNameLocked = true; - } - - - madeModification = true; - } - - if (settings.EnableSummary && (settings.HasOverride(MetadataSettingField.Summary) || - (!series.Metadata.SummaryLocked && !string.IsNullOrWhiteSpace(series.Metadata.Summary)))) - { - series.Metadata.Summary = CleanSummary(externalMetadata.Summary); - madeModification = true; - } - - if (settings.EnableStartDate && externalMetadata.StartDate.HasValue && (settings.HasOverride(MetadataSettingField.StartDate) || - (!series.Metadata.ReleaseYearLocked && - series.Metadata.ReleaseYear == 0))) - { - series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; - madeModification = true; - } - var processedGenres = new List(); var processedTags = new List(); - #region Genres and Tags + madeModification = UpdateSummary(series, settings, externalMetadata) || madeModification; + madeModification = UpdateReleaseYear(series, settings, externalMetadata) || madeModification; + madeModification = UpdateLocalizedName(series, settings, externalMetadata) || madeModification; + madeModification = await UpdatePublicationStatus(series, settings, externalMetadata) || madeModification; - // Process Genres - if (externalMetadata.Genres != null) + // Apply field mappings + GenerateGenreAndTagLists(externalMetadata, settings, ref processedTags, ref processedGenres); + + madeModification = await UpdateGenres(series, settings, externalMetadata, processedGenres) || madeModification; + madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification; + madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification; + + var staff = (externalMetadata.Staff ?? []).Select(s => { - foreach (var genre in externalMetadata.Genres) - { - // Apply field mappings - var mappedGenre = ApplyFieldMapping(genre, MetadataFieldType.Genre, settings.FieldMappings); - if (mappedGenre != null) - { - processedGenres.Add(mappedGenre); - } - } + s.Name = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}"; - // Strip blacklisted items from processedGenres - processedGenres = processedGenres - .Distinct() - .Where(g => !settings.Blacklist.Contains(g)) - .ToList(); + return s; + }).ToList(); + madeModification = await UpdateWriters(series, settings, staff) || madeModification; + madeModification = await UpdateArtists(series, settings, staff) || madeModification; + madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification; - if (settings.EnableGenres && processedGenres.Count > 0 && (!series.Metadata.GenresLocked || settings.HasOverride(MetadataSettingField.Genres))) - { - _logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name); - var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); - 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 && (!series.Metadata.TagsLocked || settings.HasOverride(MetadataSettingField.Tags))) - { - _logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name); - var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize))) - .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 - - if (!series.Metadata.AgeRatingLocked || settings.HasOverride(MetadataSettingField.AgeRating)) - { - try - { - // Determine Age Rating - var totalTags = processedGenres - .Concat(processedTags) - .Concat(series.Metadata.Genres.Select(g => g.Title)) - .Concat(series.Metadata.Tags.Select(g => g.Title)); - - var ageRating = DetermineAgeRating(totalTags, settings.AgeRatingMappings); - if (!series.Metadata.AgeRatingLocked && series.Metadata.AgeRating <= ageRating) - { - series.Metadata.AgeRating = ageRating; - _unitOfWork.SeriesRepository.Update(series); - madeModification = true; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue determining Age Rating for Series {SeriesName} ({SeriesId})", series.Name, series.Id); - } - } - #endregion - - #region People - - if (settings.EnablePeople) - { - series.Metadata.People ??= []; - - // 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 upstreamWriters = externalMetadata.Staff - .Where(s => s.Role is "Story" or "Story & Art") - .ToList(); - - var writers = upstreamWriters - .Select(w => new PersonDto() - { - Name = w.Name, - AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), - Description = CleanSummary(w.Description), - }) - .Concat(series.Metadata.People - .Where(p => p.Role == PersonRole.Writer) - .Where(p => !p.KavitaPlusConnection) - .Select(p => _mapper.Map(p.Person)) - ) - .DistinctBy(p => Parser.Normalize(p.Name)) - .ToList(); - - - // NOTE: PersonRoles can be a hashset - if (writers.Count > 0 && settings.IsPersonAllowed(PersonRole.Writer) && (!series.Metadata.WriterLocked || settings.HasOverride(MetadataSettingField.People))) - { - await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork); - - foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.Writer)) - { - var meta = upstreamWriters.FirstOrDefault(c => c.Name == person.Person.Name); - person.OrderWeight = 0; - if (meta != null) - { - person.KavitaPlusConnection = true; - } - } - - _unitOfWork.SeriesRepository.Update(series); - await _unitOfWork.CommitAsync(); - - await DownloadAndSetCovers(upstreamWriters); - - madeModification = true; - } - - var upstreamArtists = externalMetadata.Staff - .Where(s => s.Role is "Art" or "Story & Art") - .ToList(); - - var artists = upstreamArtists - .Select(w => new PersonDto() - { - Name = w.Name, - AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), - Description = CleanSummary(w.Description), - }) - .Concat(series.Metadata.People - .Where(p => p.Role == PersonRole.CoverArtist) - .Where(p => !p.KavitaPlusConnection) - .Select(p => _mapper.Map(p.Person)) - ) - .DistinctBy(p => Parser.Normalize(p.Name)) - .ToList(); - - if (artists.Count > 0 && settings.IsPersonAllowed(PersonRole.CoverArtist) && (!series.Metadata.CoverArtistLocked || settings.HasOverride(MetadataSettingField.People))) - { - await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork); - foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist)) - { - var meta = upstreamArtists.FirstOrDefault(c => c.Name == person.Person.Name); - person.OrderWeight = 0; - if (meta != null) - { - person.KavitaPlusConnection = true; - } - } - - // Download the image and save it - _unitOfWork.SeriesRepository.Update(series); - await _unitOfWork.CommitAsync(); - - await DownloadAndSetCovers(upstreamArtists); - - madeModification = true; - } - - if (externalMetadata.Characters != null && settings.IsPersonAllowed(PersonRole.Character) && (!series.Metadata.CharacterLocked || - settings.HasOverride(MetadataSettingField.People))) - { - var characters = externalMetadata.Characters - .Select(w => new PersonDto() - { - Name = w.Name, - AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), - Description = CleanSummary(w.Description), - }) - .Concat(series.Metadata.People - .Where(p => p.Role == PersonRole.Character) - // Need to ensure existing people are retained, but we overwrite anything from a bad match - .Where(p => !p.KavitaPlusConnection) - .Select(p => _mapper.Map(p.Person)) - ) - .DistinctBy(p => Parser.Normalize(p.Name)) - .ToList(); - - - if (characters.Count > 0) - { - await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); - foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) - { - // Set a sort order based on their role - var characterMeta = externalMetadata.Characters?.FirstOrDefault(c => c.Name == spPerson.Person.Name); - spPerson.OrderWeight = 0; - if (characterMeta != null) - { - spPerson.KavitaPlusConnection = true; - - spPerson.OrderWeight = characterMeta.Role switch - { - CharacterRole.Main => 0, - CharacterRole.Supporting => 1, - CharacterRole.Background => 2, - _ => 99 // Default for unknown roles - }; - } - } - - // Download the image and save it - _unitOfWork.SeriesRepository.Update(series); - await _unitOfWork.CommitAsync(); - - foreach (var character in externalMetadata.Characters ?? []) - { - var aniListId = ScrobblingService.ExtractId(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.SetPersonCoverByUrl(person, character.ImageUrl, false); - } - } - - madeModification = true; - } - } - } - - #endregion - - #region Publication Status - - if (settings.EnablePublicationStatus && (!series.Metadata.PublicationStatusLocked || - settings.HasOverride(MetadataSettingField.PublicationStatus))) - { - try - { - 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; - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue determining Publication Status for Series {SeriesName} ({SeriesId})", series.Name, series.Id); - } - } - #endregion - - #region Relationships - - 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; - } - } - #endregion - - #region Series Cover - - // This must not allow cover image locked to be off after downloading, else it will call every time a match is hit - if (!string.IsNullOrEmpty(externalMetadata.CoverUrl) && (!series.CoverImageLocked || settings.HasOverride(MetadataSettingField.Covers))) - { - await DownloadSeriesCovers(series, externalMetadata.CoverUrl); - } - - #endregion + madeModification = await UpdateRelationships(series, settings, externalMetadata.Relations, defaultAdmin) || madeModification; + madeModification = await UpdateCoverImage(series, settings, externalMetadata) || madeModification; return madeModification; } + private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings, + ref List processedTags, ref List processedGenres) + { + externalMetadata.Tags ??= []; + externalMetadata.Genres ??= []; + + var mappings = ApplyFieldMappings(externalMetadata.Tags.Select(t => t.Name), MetadataFieldType.Tag, settings.FieldMappings); + if (mappings.TryGetValue(MetadataFieldType.Tag, out var tagsToTags)) + { + processedTags.AddRange(tagsToTags); + } + if (mappings.TryGetValue(MetadataFieldType.Genre, out var tagsToGenres)) + { + processedGenres.AddRange(tagsToGenres); + } + + mappings = ApplyFieldMappings(externalMetadata.Genres, MetadataFieldType.Genre, settings.FieldMappings); + if (mappings.TryGetValue(MetadataFieldType.Tag, out var genresToTags)) + { + processedTags.AddRange(genresToTags); + } + if (mappings.TryGetValue(MetadataFieldType.Genre, out var genresToGenres)) + { + processedGenres.AddRange(genresToGenres); + } + + processedTags = ApplyBlackWhiteList(settings, MetadataFieldType.Tag, processedTags); + processedGenres = ApplyBlackWhiteList(settings, MetadataFieldType.Genre, processedGenres); + } + + private async Task UpdateRelationships(Series series, MetadataSettingsDto settings, IList? externalMetadataRelations, AppUser defaultAdmin) + { + if (!settings.EnableRelationships) return false; + + if (externalMetadataRelations == null || externalMetadataRelations.Count == 0 || defaultAdmin == null) + { + return false; + } + + var relatedSeriesDict = new Dictionary(); + foreach (var relation in externalMetadataRelations) + { + var names = new [] {relation.SeriesName.PreferredTitle, relation.SeriesName.RomajiTitle, relation.SeriesName.EnglishTitle, relation.SeriesName.NativeTitle}; + var relatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName( + names, + 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; + + relatedSeriesDict[relatedSeries.Id] = relatedSeries; + } + + // Process relationships + foreach (var relation in externalMetadataRelations) + { + var relatedSeries = relatedSeriesDict.GetValueOrDefault( + relatedSeriesDict.Keys.FirstOrDefault(k => + relatedSeriesDict[k].Name == relation.SeriesName.PreferredTitle || + relatedSeriesDict[k].Name == relation.SeriesName.NativeTitle)); + + if (relatedSeries == null) continue; + + // Add new relationship + var newRelation = new SeriesRelation + { + RelationKind = relation.Relation, + TargetSeriesId = relatedSeries.Id, + SeriesId = series.Id, + }; + series.Relations.Add(newRelation); + + // 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) + { + var reverseRelation = new SeriesRelation + { + RelationKind = GetReverseRelation(relation.Relation), + TargetSeriesId = series.Id, + SeriesId = relatedSeries.Id, + }; + relatedSeries.Relations.Add(reverseRelation); + _unitOfWork.SeriesRepository.Attach(reverseRelation); + } + } + + _unitOfWork.SeriesRepository.Update(series); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + return true; + } + + private async Task UpdateCharacters(Series series, MetadataSettingsDto settings, IList? externalCharacters) + { + if (!settings.EnablePeople) return false; + + if (externalCharacters == null || externalCharacters.Count == 0) return false; + + if (series.Metadata.CharacterLocked && !settings.HasOverride(MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.Character)) + { + return false; + } + + series.Metadata.People ??= []; + + var characters = externalCharacters + .Select(w => new PersonDto() + { + Name = w.Name, + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), + Description = StringHelper.SquashBreaklines(w.Description), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Character) + // Need to ensure existing people are retained, but we overwrite anything from a bad match + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + if (characters.Count == 0) return false; + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); + foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) + { + // Set a sort order based on their role + var characterMeta = externalCharacters.FirstOrDefault(c => c.Name == spPerson.Person.Name); + spPerson.OrderWeight = 0; + + if (characterMeta != null) + { + spPerson.KavitaPlusConnection = true; + + spPerson.OrderWeight = characterMeta.Role switch + { + CharacterRole.Main => 0, + CharacterRole.Supporting => 1, + CharacterRole.Background => 2, + _ => 99 // Default for unknown roles + }; + } + } + + // Download the image and save it + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + foreach (var character in externalCharacters) + { + var aniListId = ScrobblingService.ExtractId(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.SetPersonCoverByUrl(person, character.ImageUrl, false); + } + } + + + return true; + } + + private async Task UpdateArtists(Series series, MetadataSettingsDto settings, List staff) + { + if (!settings.EnablePeople) return false; + + + var upstreamArtists = staff + .Where(s => s.Role is "Art" or "Story & Art") + .ToList(); + + if (upstreamArtists.Count == 0) return false; + + if (series.Metadata.CoverArtistLocked && !settings.HasOverride(MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.CoverArtist)) + { + return false; + } + + series.Metadata.People ??= []; + var artists = upstreamArtists + .Select(w => new PersonDto() + { + Name = w.Name, + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = StringHelper.SquashBreaklines(w.Description), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.CoverArtist) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork); + + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist)) + { + var meta = upstreamArtists.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + // Download the image and save it + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetCovers(upstreamArtists); + + return true; + } + + private async Task UpdateWriters(Series series, MetadataSettingsDto settings, List staff) + { + if (!settings.EnablePeople) return false; + + var upstreamWriters = staff + .Where(s => s.Role is "Story" or "Story & Art") + .ToList(); + + if (upstreamWriters.Count == 0) return false; + + if (series.Metadata.WriterLocked && !settings.HasOverride(MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.Writer)) + { + return false; + } + + series.Metadata.People ??= []; + var writers = upstreamWriters + .Select(w => new PersonDto() + { + Name = w.Name, + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = StringHelper.SquashBreaklines(w.Description), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Writer) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork); + + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.Writer)) + { + var meta = upstreamWriters.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetCovers(upstreamWriters); + + return true; + } + + private async Task UpdateTags(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedTags) + { + externalMetadata.Tags ??= []; + + if (!settings.EnableTags || processedTags.Count == 0) return false; + + if (series.Metadata.TagsLocked && !settings.HasOverride(MetadataSettingField.Tags)) + { + return false; + } + + _logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name); + var madeModification = false; + 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); + + return madeModification; + } + + private static List ApplyBlackWhiteList(MetadataSettingsDto settings, MetadataFieldType fieldType, List processedStrings) + { + return fieldType switch + { + MetadataFieldType.Genre => processedStrings.Distinct() + .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) + .ToList(), + MetadataFieldType.Tag => processedStrings.Distinct() + .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) + .Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g)) + .ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null) + }; + } + + private async Task UpdateGenres(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedGenres) + { + externalMetadata.Genres ??= []; + + if (!settings.EnableGenres || processedGenres.Count == 0) return false; + + if (series.Metadata.GenresLocked && !settings.HasOverride(MetadataSettingField.Genres)) + { + return false; + } + + _logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name); + var madeModification = false; + var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); + series.Metadata.Genres ??= []; + var exisitingGenres = series.Metadata.Genres; + + GenreHelper.UpdateGenreList(processedGenres, series, allGenres, genre => + { + series.Metadata.Genres.Add(genre); + madeModification = true; + }, () => series.Metadata.GenresLocked = true); + + foreach (var genre in exisitingGenres) + { + if (series.Metadata.Genres.FirstOrDefault(g => g.NormalizedTitle == genre.NormalizedTitle) != null) continue; + series.Metadata.Genres.Add(genre); + } + + return madeModification; + } + + private async Task UpdatePublicationStatus(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnablePublicationStatus) return false; + + if (series.Metadata.PublicationStatusLocked && !settings.HasOverride(MetadataSettingField.PublicationStatus)) + { + return false; + } + + try + { + var chapters = + (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes + .SelectMany(v => v.Chapters).ToList(); + var status = DeterminePublicationStatus(series, chapters, externalMetadata); + + series.Metadata.PublicationStatus = status; + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue determining Publication Status for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + + return false; + } + + private bool UpdateAgeRating(Series series, MetadataSettingsDto settings, IEnumerable allExternalTags) + { + + if (series.Metadata.AgeRatingLocked && !settings.HasOverride(MetadataSettingField.AgeRating)) + { + return false; + } + + try + { + // Determine Age Rating + var totalTags = allExternalTags + .Concat(series.Metadata.Genres.Select(g => g.Title)) + .Concat(series.Metadata.Tags.Select(g => g.Title)); + + var ageRating = DetermineAgeRating(totalTags, settings.AgeRatingMappings); + if (series.Metadata.AgeRating <= ageRating) + { + series.Metadata.AgeRating = ageRating; + return true; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue determining Age Rating for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + + return false; + } + + private async Task UpdateCoverImage(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableCoverImage) return false; + + if (string.IsNullOrEmpty(externalMetadata.CoverUrl)) return false; + + if (series.CoverImageLocked && !settings.HasOverride(MetadataSettingField.Covers)) + { + return false; + } + + if (!string.IsNullOrEmpty(externalMetadata.CoverUrl) && !settings.HasOverride(MetadataSettingField.Covers)) + { + return false; + } + + await DownloadSeriesCovers(series, externalMetadata.CoverUrl); + return true; + } + + + private static bool UpdateReleaseYear(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableStartDate) return false; + + if (!externalMetadata.StartDate.HasValue) return false; + + if (series.Metadata.ReleaseYearLocked && !settings.HasOverride(MetadataSettingField.StartDate)) + { + return false; + } + + if (series.Metadata.ReleaseYear != 0 && !settings.HasOverride(MetadataSettingField.StartDate)) + { + return false; + } + + series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; + return true; + } + + private static bool UpdateLocalizedName(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableLocalizedName) return false; + + if (series.LocalizedNameLocked && !settings.HasOverride(MetadataSettingField.LocalizedName)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(series.LocalizedName) && !settings.HasOverride(MetadataSettingField.LocalizedName)) + { + return false; + } + + // We need to make the best appropriate guess + if (externalMetadata.Name == series.Name) + { + // Choose closest (usually last) synonym + var validSynonyms = externalMetadata.Synonyms + .Where(IsRomanCharacters) + .Where(s => s.ToNormalized() != series.Name.ToNormalized()) + .ToList(); + + if (validSynonyms.Count == 0) return false; + + series.LocalizedName = validSynonyms[^1]; + series.LocalizedNameLocked = true; + } + else if (IsRomanCharacters(externalMetadata.Name)) + { + series.LocalizedName = externalMetadata.Name; + series.LocalizedNameLocked = true; + } + + + return true; + } + + private static bool UpdateSummary(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableSummary) return false; + + if (string.IsNullOrEmpty(externalMetadata.Summary)) return false; + + if (series.Metadata.SummaryLocked && !settings.HasOverride(MetadataSettingField.Summary)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(series.Metadata.Summary) && !settings.HasOverride(MetadataSettingField.Summary)) + { + return false; + } + + series.Metadata.Summary = StringHelper.SquashBreaklines(externalMetadata.Summary); + return true; + } + private static RelationKind GetReverseRelation(RelationKind relation) { @@ -954,9 +1094,8 @@ public class ExternalMetadataService : IExternalMetadataService } } - private bool DeterminePublicationStatus(Series series, List chapters, ExternalSeriesDetailDto externalMetadata) + private PublicationStatus DeterminePublicationStatus(Series series, List chapters, ExternalSeriesDetailDto externalMetadata) { - var madeModification = false; try { // Determine the expected total count based on local metadata @@ -1010,35 +1149,61 @@ public class ExternalMetadataService : IExternalMetadataService { status = PublicationStatus.Completed; } - - madeModification = true; } - series.Metadata.PublicationStatus = status; + return status; } catch (Exception ex) { _logger.LogCritical(ex, "There was an issue determining Publication Status"); - series.Metadata.PublicationStatus = PublicationStatus.OnGoing; } - return madeModification; + return PublicationStatus.OnGoing; } - private static string? ApplyFieldMapping(string value, MetadataFieldType sourceType, List mappings) + private static Dictionary> ApplyFieldMappings(IEnumerable values, MetadataFieldType sourceType, List mappings) { - // Find matching mapping - var mapping = mappings - .FirstOrDefault(m => + var result = new Dictionary>(); + + foreach (var field in Enum.GetValues()) + { + result[field] = []; + } + + foreach (var value in values) + { + var mapping = mappings.FirstOrDefault(m => m.SourceType == sourceType && m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase)); - if (mapping == null) return value; + if (mapping != null && !string.IsNullOrWhiteSpace(mapping.DestinationValue)) + { + var targetType = mapping.DestinationType; - // If mapping exists, return destination or source value - return mapping.DestinationValue ?? (mapping.ExcludeFromSource ? null : value); + if (!mapping.ExcludeFromSource) + { + result[sourceType].Add(mapping.SourceValue); + } + + result[targetType].Add(mapping.DestinationValue); + } + else + { + // If no mapping, keep the original value + result[sourceType].Add(value); + } + } + + // Ensure distinct + foreach (var key in result.Keys) + { + result[key] = result[key].Distinct().ToList(); + } + + return result; } + /// /// Returns the highest age rating from all tags/genres based on user-supplied mappings /// diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 38e75c2a3..282830276 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -1,20 +1,15 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; -using API.Constants; -using API.Controllers; using API.Data; using API.Data.Repositories; using API.DTOs; -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; @@ -22,7 +17,6 @@ using API.Helpers.Builders; using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using API.SignalR; -using EasyCaching.Core; using Hangfire; using Kavita.Common; using Microsoft.Extensions.Logging; @@ -56,7 +50,6 @@ public class SeriesService : ISeriesService private readonly ILogger _logger; private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; - private readonly IImageService _imageService; private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto { @@ -66,7 +59,7 @@ public class SeriesService : ISeriesService }; public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, - ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService, IImageService imageService) + ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; _eventHub = eventHub; @@ -74,7 +67,6 @@ public class SeriesService : ISeriesService _logger = logger; _scrobblingService = scrobblingService; _localizationService = localizationService; - _imageService = imageService; } /// diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 4d511f85a..1c7a960ca 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -124,6 +124,8 @@ export class AppComponent implements OnInit { // Bootstrap anything that's needed this.themeService.getThemes().subscribe(); this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe(); - this.licenseService.licenseInfo().subscribe(); + if (this.accountService.hasAdminRole(user)) { + this.licenseService.licenseInfo().subscribe(); + } } } diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index e304f5bcc..d29b6f44b 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -429,7 +429,7 @@
- diff --git a/openapi.json b/openapi.json index 33c00de85..9016c4dd9 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.12", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.13", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"