diff --git a/.github/workflows/openapi-gen.yml b/.github/workflows/openapi-gen.yml index 517334ead..28897acf8 100644 --- a/.github/workflows/openapi-gen.yml +++ b/.github/workflows/openapi-gen.yml @@ -21,6 +21,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + token: ${{ secrets.REPO_GHA_PAT }} - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -59,7 +60,12 @@ jobs: run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" + + # Pull latest changes with rebase to avoid merge commits + git pull --rebase origin develop + + # Commit and push git commit -m "Update OpenAPI documentation" openapi.json git push env: - GITHUB_TOKEN: ${{ secrets.REPO_GHA_PAT }} + GITHUB_TOKEN: ${{ secrets.REPO_GHA_PAT }} \ No newline at end of file diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 8c0e81b03..71e8785fa 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -298,38 +298,38 @@ public class ScannerServiceTests : AbstractDbTest } - [Fact] - public async Task ScanLibrary_PublishersInheritFromChapters() - { - const string testcase = "Flat Special - Manga.json"; - - var infos = new Dictionary(); - infos.Add("Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz", new ComicInfo() + [Fact] + public async Task ScanLibrary_PublishersInheritFromChapters() { - Publisher = "Correct Publisher" - }); - infos.Add("Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", new ComicInfo() - { - Publisher = "Special Publisher" - }); - infos.Add("Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", new ComicInfo() - { - Publisher = "Chapter Publisher" - }); + const string testcase = "Flat Special - Manga.json"; - var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var infos = new Dictionary(); + infos.Add("Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz", new ComicInfo() + { + Publisher = "Correct Publisher" + }); + infos.Add("Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", new ComicInfo() + { + Publisher = "Special Publisher" + }); + infos.Add("Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", new ComicInfo() + { + Publisher = "Chapter Publisher" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); - var scanner = _scannerHelper.CreateServices(); - await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); - Assert.NotNull(postLib); - Assert.Single(postLib.Series); - var publishers = postLib.Series.First().Metadata.People - .Where(p => p.Role == PersonRole.Publisher); - Assert.Equal(3, publishers.Count()); - } + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var publishers = postLib.Series.First().Metadata.People + .Where(p => p.Role == PersonRole.Publisher); + Assert.Equal(3, publishers.Count()); + } /// diff --git a/API/API.csproj b/API/API.csproj index 8bc02df40..4052c5a9d 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -12,11 +12,6 @@ latestmajor - - - - - false diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs index 8ac3d434a..77e35540e 100644 --- a/API/Controllers/LicenseController.cs +++ b/API/Controllers/LicenseController.cs @@ -7,6 +7,7 @@ using API.Entities.Enums; using API.Extensions; using API.Services; using API.Services.Plus; +using EasyCaching.Core; using Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -21,7 +22,8 @@ public class LicenseController( ILogger logger, ILicenseService licenseService, ILocalizationService localizationService, - ITaskScheduler taskScheduler) + ITaskScheduler taskScheduler, + IEasyCachingProviderFactory cachingProviderFactory) : BaseApiController { /// @@ -32,8 +34,13 @@ public class LicenseController( [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] public async Task> HasValidLicense(bool forceCheck = false) { + var result = await licenseService.HasActiveLicense(forceCheck); - if (result) + + var licenseInfoProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + var cacheValue = await licenseInfoProvider.GetAsync(LicenseService.CacheKey); + + if (result && !cacheValue.IsNull && !cacheValue.Value) { await taskScheduler.ScheduleKavitaPlusTasks(); } diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 6e594aa73..b3e543315 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using API.Entities.Enums; using API.Entities.Interfaces; using Microsoft.EntityFrameworkCore; @@ -101,4 +102,24 @@ public class SeriesMetadata : IHasConcurrencyToken { RowVersion++; } + + /// + /// Any People in this Role present + /// + /// + /// + public bool AnyOfRole(PersonRole role) + { + return People.Any(p => p.Role == role); + } + + /// + /// Are all instances of the role from Kavita+ + /// + /// + /// + public bool AllKavitaPlus(PersonRole role) + { + return People.Where(p => p.Role == role).All(p => p.KavitaPlusConnection); + } } diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 14a05d82f..76e18eb3c 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -295,7 +295,16 @@ public class ExternalMetadataService : IExternalMetadataService if (data == null) return _defaultReturn; // Get from Kavita+ API the Full Series metadata with rec/rev and cache to ExternalMetadata tables - return await FetchExternalMetadataForSeries(seriesId, libraryType, data); + try + { + return await FetchExternalMetadataForSeries(seriesId, libraryType, data); + } + catch (KavitaException ex) + { + _logger.LogError(ex, "Rate limit hit fetching metadata"); + // This can happen when we hit rate limit + return _defaultReturn; + } } /// @@ -314,38 +323,49 @@ public class ExternalMetadataService : IExternalMetadataService _unitOfWork.SeriesRepository.Update(series); // Refetch metadata with a Direct lookup - var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type, new PlusSeriesRequestDto() + try { - AniListId = anilistId, - MalId = malId, - SeriesName = series.Name // Required field, not used since AniList/Mal Id are passed - }); + var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type, + new PlusSeriesRequestDto() + { + AniListId = anilistId, + MalId = malId, + SeriesName = series.Name // Required field, not used since AniList/Mal Id are passed + }); - if (metadata.Series == null) - { - _logger.LogError("Unable to Match {SeriesName} with Kavita+ Series AniList Id: {AniListId}", series.Name, anilistId); - return; + if (metadata.Series == null) + { + _logger.LogError("Unable to Match {SeriesName} with Kavita+ Series AniList Id: {AniListId}", + series.Name, anilistId); + return; + } + + // Find all scrobble events and rewrite them to be the correct + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + _unitOfWork.ScrobbleRepository.Remove(events); + + // Find all scrobble errors and remove them + var errors = await _unitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(seriesId); + _unitOfWork.ScrobbleRepository.Remove(errors); + + await _unitOfWork.CommitAsync(); + + // Regenerate all events for the series for all users + BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistoryForSeries(seriesId)); + + // Name can be null on Series even with a direct match + _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, + metadata.Series.Name); + } + catch (KavitaException ex) + { + // We can't rethrow because Fix match is done in a background thread and Hangfire will requeue multiple times + _logger.LogInformation(ex, "Rate limit hit for matching {SeriesName} with Kavita+", series.Name); } - - // Find all scrobble events and rewrite them to be the correct - var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); - _unitOfWork.ScrobbleRepository.Remove(events); - - // Find all scrobble errors and remove them - var errors = await _unitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(seriesId); - _unitOfWork.ScrobbleRepository.Remove(errors); - - await _unitOfWork.CommitAsync(); - - // Regenerate all events for the series for all users - BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistoryForSeries(seriesId)); - - // Name can be null on Series even with a direct match - _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, metadata.Series.Name); } /// - /// Sets a series to Dont Match and removes all previously cached + /// Sets a series to Don't Match and removes all previously cached /// /// public async Task UpdateSeriesDontMatch(int seriesId, bool dontMatch) @@ -383,7 +403,10 @@ public class ExternalMetadataService : IExternalMetadataService { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); - if (series == null) return _defaultReturn; + if (series == null) + { + return _defaultReturn; + } try { @@ -417,7 +440,7 @@ public class ExternalMetadataService : IExternalMetadataService // Recommendations - externalSeriesMetadata.ExternalRecommendations ??= new List(); + externalSeriesMetadata.ExternalRecommendations ??= []; var recs = await ProcessRecommendations(libraryType, result.Recommendations, externalSeriesMetadata); var extRatings = externalSeriesMetadata.ExternalRatings @@ -437,11 +460,19 @@ public class ExternalMetadataService : IExternalMetadataService { externalSeriesMetadata.Series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId); - if (madeMetadataModification) + try { - _unitOfWork.SeriesRepository.Update(series); + madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId); + if (madeMetadataModification) + { + _unitOfWork.SeriesRepository.Update(series); + } } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to write Series metadata from Kavita+"); + } + } // WriteExternalMetadataToSeries will commit but not always @@ -466,13 +497,27 @@ public class ExternalMetadataService : IExternalMetadataService } catch (FlurlHttpException ex) { + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + if (ex.StatusCode == 500) { return _defaultReturn; } + + if (ex.StatusCode == 400 && errorMessage.Contains("Too many Requests")) + { + throw new KavitaException("Too many requests, slow down"); + } } catch (Exception ex) { + if (ex.Message.Contains("Too Many Requests")) + { + throw new KavitaException("Too many requests, slow down"); + } + _logger.LogError(ex, "Unable to fetch external series metadata from Kavita+"); } @@ -1079,10 +1124,18 @@ public class ExternalMetadataService : IExternalMetadataService var aniListId = ScrobblingService.ExtractId(staff.Url, ScrobblingService.AniListStaffWebsite); if (aniListId is null or <= 0) continue; var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value); - if (person != null && !string.IsNullOrEmpty(staff.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) + if (person == null || string.IsNullOrEmpty(staff.ImageUrl) || + !string.IsNullOrEmpty(person.CoverImage) || staff.ImageUrl.EndsWith("default.jpg")) continue; + + try { await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false, true); } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception saving cover image for Person {PersonName} ({PersonId})", person.Name, person.Id); + } + } } diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index fb513e248..de5bbd1ae 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -44,7 +44,10 @@ public class LicenseService( { private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8); public const string Cron = "0 */9 * * *"; - private const string CacheKey = "license"; + /// + /// Cache key for if license is valid or not + /// + public const string CacheKey = "license"; private const string LicenseInfoCacheKey = "license-info"; diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index f94980c66..3ef5a7f82 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -19,7 +19,6 @@ using API.SignalR; using Flurl.Http; using Hangfire; using Kavita.Common; -using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -77,7 +76,7 @@ public class ScrobblingService : IScrobblingService public const string AniListCharacterWebsite = "https://anilist.co/character/"; - private static readonly IDictionary WeblinkExtractionMap = new Dictionary() + private static readonly Dictionary WeblinkExtractionMap = new Dictionary() { {AniListWeblinkWebsite, 0}, {MalWeblinkWebsite, 0}, @@ -89,18 +88,14 @@ public class ScrobblingService : IScrobblingService private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90) - private static readonly IList BookProviders = new List() - { - }; - private static readonly IList LightNovelProviders = new List() - { + private static readonly IList BookProviders = []; + private static readonly IList LightNovelProviders = + [ ScrobbleProvider.AniList - }; - private static readonly IList ComicProviders = new List(); - private static readonly IList MangaProviders = new List() - { - ScrobbleProvider.AniList - }; + ]; + private static readonly IList ComicProviders = []; + private static readonly IList MangaProviders = (List) + [ScrobbleProvider.AniList]; private const string UnknownSeriesErrorMessage = "Series cannot be matched for Scrobbling"; @@ -532,11 +527,10 @@ public class ScrobblingService : IScrobblingService { // Create a new ExternalMetadata entry to indicate that this is not matchable var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(evt.SeriesId, SeriesIncludes.ExternalMetadata); - if (series.ExternalSeriesMetadata == null) - { - series.ExternalSeriesMetadata = new ExternalSeriesMetadata() {SeriesId = evt.SeriesId}; - } - series!.IsBlacklisted = true; + if (series == null) return 0; + + series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata() {SeriesId = evt.SeriesId}; + series.IsBlacklisted = true; _unitOfWork.SeriesRepository.Update(series); _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() @@ -824,6 +818,7 @@ public class ScrobblingService : IScrobblingService readEvt.AppUser.Id); _unitOfWork.ScrobbleRepository.Update(readEvt); } + progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, async evt => new ScrobbleDto() { Format = evt.Format, @@ -888,9 +883,9 @@ public class ScrobblingService : IScrobblingService await _unitOfWork.CommitAsync(); } } - catch (FlurlHttpException) + catch (FlurlHttpException ex) { - _logger.LogError("Kavita+ API or a Scrobble service may be experiencing an outage. Stopping sending data"); + _logger.LogError(ex, "Kavita+ API or a Scrobble service may be experiencing an outage. Stopping sending data"); return; } @@ -986,7 +981,7 @@ public class ScrobblingService : IScrobblingService { if (ex.Message.Contains("Access token is invalid")) { - _logger.LogCritical("Access Token for UserId: {UserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id); + _logger.LogCritical(ex, "Access Token for UserId: {UserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id); evt.IsErrored = true; evt.ErrorDetails = AccessTokenErrorMessage; _unitOfWork.ScrobbleRepository.Update(evt); @@ -1106,7 +1101,7 @@ public class ScrobblingService : IScrobblingService return null; // Unsupported website } - if (id == null) + if (Equals(id, default(T))) { throw new ArgumentNullException(nameof(id), "ID cannot be null."); } @@ -1140,7 +1135,7 @@ public class ScrobblingService : IScrobblingService } catch (Exception ex) { - _logger.LogInformation("User {UserName} had an issue figuring out rate: {Message}", user.UserName, ex.Message); + _logger.LogInformation(ex, "User {UserName} had an issue figuring out rate: {Message}", user.UserName, ex.Message); userRateLimits.Add(user.Id, 0); } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index ce8c2b41d..8f21c8a04 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -331,84 +331,123 @@ public class ProcessSeries : IProcessSeries } #region PeopleAndTagsAndGenres - if (!series.Metadata.WriterLocked) - { - var personSw = Stopwatch.StartNew(); - var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); - _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count); - } + if (!series.Metadata.WriterLocked) + { + var personSw = Stopwatch.StartNew(); + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); + } + _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count); + } if (!series.Metadata.ColoristLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Colorist)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Colorist); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Colorist)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Colorist); + } } if (!series.Metadata.PublisherLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Publisher)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Publisher); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Publisher)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Publisher); + } } if (!series.Metadata.CoverArtistLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.CoverArtist)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.CoverArtist); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.CoverArtist)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.CoverArtist); + } } if (!series.Metadata.CharacterLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Character)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Character); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Character)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Character); + } } if (!series.Metadata.EditorLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Editor)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Editor); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Editor)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Editor); + } } if (!series.Metadata.InkerLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Inker)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Inker); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Inker)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Inker); + } } if (!series.Metadata.ImprintLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Imprint)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Imprint); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Imprint)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Imprint); + } } if (!series.Metadata.TeamLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Team)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Team); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Team)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Team); + } } - if (!series.Metadata.LocationLocked) + if (!series.Metadata.LocationLocked && !series.Metadata.AllKavitaPlus(PersonRole.Location)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Location)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Location); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Location)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Location); + } } - if (!series.Metadata.LettererLocked) + if (!series.Metadata.LettererLocked && !series.Metadata.AllKavitaPlus(PersonRole.Letterer)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Letterer)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Letterer); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Location)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Letterer); + } } - if (!series.Metadata.PencillerLocked) + if (!series.Metadata.PencillerLocked && !series.Metadata.AllKavitaPlus(PersonRole.Penciller)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Penciller)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Penciller); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Penciller)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Penciller); + } } - if (!series.Metadata.TranslatorLocked) + if (!series.Metadata.TranslatorLocked && !series.Metadata.AllKavitaPlus(PersonRole.Translator)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Translator)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Translator); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Translator)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Translator); + } } @@ -428,6 +467,34 @@ public class ProcessSeries : IProcessSeries } + /// + /// Ensure that we don't overwrite Person metadata when all metadata is coming from Kavita+ metadata match functionality + /// + /// + /// + /// + /// + private static bool ShouldUpdatePeopleForRole(Series series, List chapterPeople, PersonRole role) + { + if (chapterPeople.Count == 0) return false; + + // If metadata already has this role, but all entries are from KavitaPlus, we should retain them + if (series.Metadata.AnyOfRole(role)) + { + var existingPeople = series.Metadata.People.Where(p => p.Role == role); + + // If all existing people are KavitaPlus but new chapter people exist, we should still update + if (existingPeople.All(p => p.KavitaPlusConnection)) + { + return false; // Ensure we don't remove KavitaPlus people + } + + return true; // Default case: metadata exists, and it's okay to update + } + + return true; + } + private async Task UpdateCollectionTags(Series series, Chapter firstChapter) { // Get the default admin to associate these tags to @@ -553,8 +620,8 @@ public class ProcessSeries : IProcessSeries .Where(v => v.MaxNumber.IsNot(Parser.Parser.SpecialVolumeNumber)) .ToList(); - var maxVolume = (int) (nonSpecialVolumes.Any() ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); - var maxChapter = (int) chapters.Max(c => c.MaxNumber); + var maxVolume = (int)(nonSpecialVolumes.Any() ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); + var maxChapter = (int)chapters.Max(c => c.MaxNumber); // Single books usually don't have a number in their Range (filename) if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1)