diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index cd9e5c142..8a8186e42 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -43,6 +43,7 @@ protected AbstractDbTest() { var contextOptions = new DbContextOptionsBuilder() .UseSqlite(CreateInMemoryDatabase()) + .EnableSensitiveDataLogging() .Options; _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; @@ -93,6 +94,7 @@ private async Task SeedDb() _context.Library.Add(new LibraryBuilder("Manga") + .WithAllowMetadataMatching(true) .WithFolderPath(new FolderPathBuilder(DataDirectory).Build()) .Build()); diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index b460cf112..264aa9a1d 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -7,6 +7,7 @@ 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; @@ -15,6 +16,7 @@ using API.Services.Tasks.Metadata; using API.SignalR; using Hangfire; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -1974,8 +1976,246 @@ await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesD #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() + { + 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, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.Annual); + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.SideStory); + } + + + // Sequel/Prequel @@ -2433,8 +2673,6 @@ await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesD - - protected override async Task ResetDb() { _context.Series.RemoveRange(_context.Series); @@ -2458,7 +2696,10 @@ protected override async Task ResetDb() await _context.SaveChangesAsync(); - _context.AppUser.Add(new AppUserBuilder("Joe", "Joe").WithRole(PolicyConstants.AdminRole).Build()); + _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(); 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 async Task> HasLicense() [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/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 479b5cd19..14efa679c 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -146,6 +146,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); @@ -1757,6 +1760,41 @@ public async Task> GetAllSeriesByNameAsync(IList nor .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/Helpers/Builders/SeriesBuilder.cs b/API/Helpers/Builders/SeriesBuilder.cs index 574f7f7d2..c42ec904c 100644 --- a/API/Helpers/Builders/SeriesBuilder.cs +++ b/API/Helpers/Builders/SeriesBuilder.cs @@ -119,4 +119,16 @@ public SeriesBuilder WithExternalMetadata(ExternalSeriesMetadata metadata) } + public SeriesBuilder WithRelationship(int targetSeriesId, RelationKind kind) + { + _series.Relations ??= []; + _series.Relations.Add(new SeriesRelation() + { + SeriesId = _series.Id, + RelationKind = kind, + TargetSeriesId = targetSeriesId + }); + + return this; + } } diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 3f8e2e655..e07017ba8 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -438,8 +438,11 @@ private async Task FetchExternalMetadataForSeries(int serie } } - - await _unitOfWork.CommitAsync(); + // WriteExternalMetadataToSeries will commit but not always + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } if (madeMetadataModification) { @@ -565,11 +568,12 @@ private async Task UpdateRelationships(Series series, MetadataSettingsDto 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( - relation.SeriesName.NativeTitle, - relation.SeriesName.PreferredTitle, + names, relation.PlusMediaFormat.GetMangaFormats(), defaultAdmin.Id, relation.AniListId, @@ -578,20 +582,68 @@ private async Task UpdateRelationships(Series series, MetadataSettingsDto // Skip if no related series found or series is the parent if (relatedSeries == null || relatedSeries.Id == series.Id || relation.Relation == RelationKind.Parent) continue; + if (relatedSeries != null && relatedSeries.Id != series.Id && relation.Relation != RelationKind.Parent) + { + relatedSeriesDict[relatedSeries.Id] = relatedSeries; + } + + // // 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, + // }); + // + // _unitOfWork.SeriesRepository.Attach(series); + // + // // 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 + // }); + // } + } + + // 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; + // 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 + // Add new relationship + var newRelation = new SeriesRelation { RelationKind = relation.Relation, - TargetSeries = relatedSeries, - TargetSeriesId = relatedSeries.Id, - Series = series, - SeriesId = series.Id - }); + TargetSeriesId = relatedSeries.Id + }; + series.Relations.Add(newRelation); // Handle sequel/prequel: add reverse relationship if (relation.Relation is RelationKind.Prequel or RelationKind.Sequel) @@ -599,19 +651,23 @@ private async Task UpdateRelationships(Series series, MetadataSettingsDto var reverseExists = relatedSeries.Relations.Any(r => r.TargetSeriesId == series.Id && r.RelationKind == GetReverseRelation(relation.Relation)); - if (reverseExists) continue; - - relatedSeries.Relations.Add(new SeriesRelation + if (!reverseExists) { - RelationKind = GetReverseRelation(relation.Relation), - TargetSeries = series, - TargetSeriesId = series.Id, - Series = relatedSeries, - SeriesId = relatedSeries.Id - }); + var reverseRelation = new SeriesRelation + { + RelationKind = GetReverseRelation(relation.Relation), + TargetSeriesId = series.Id + }; + relatedSeries.Relations.Add(reverseRelation); + } } } + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + return true; } 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(); + } } }