This commit is contained in:
Joe Milazzo 2025-03-02 17:55:23 -06:00 committed by GitHub
parent 78a98d0d18
commit 5af851af08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 264 additions and 117 deletions

View File

@ -21,6 +21,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.REPO_GHA_PAT }}
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
@ -59,7 +60,12 @@ jobs:
run: | run: |
git config --local user.email "action@github.com" git config --local user.email "action@github.com"
git config --local user.name "GitHub Action" 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 commit -m "Update OpenAPI documentation" openapi.json
git push git push
env: env:
GITHUB_TOKEN: ${{ secrets.REPO_GHA_PAT }} GITHUB_TOKEN: ${{ secrets.REPO_GHA_PAT }}

View File

@ -298,38 +298,38 @@ public class ScannerServiceTests : AbstractDbTest
} }
[Fact] [Fact]
public async Task ScanLibrary_PublishersInheritFromChapters() public async Task ScanLibrary_PublishersInheritFromChapters()
{
const string testcase = "Flat Special - Manga.json";
var infos = new Dictionary<string, ComicInfo>();
infos.Add("Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz", new ComicInfo()
{ {
Publisher = "Correct Publisher" const string testcase = "Flat Special - Manga.json";
});
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 infos = new Dictionary<string, ComicInfo>();
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(); var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib); Assert.NotNull(postLib);
Assert.Single(postLib.Series); Assert.Single(postLib.Series);
var publishers = postLib.Series.First().Metadata.People var publishers = postLib.Series.First().Metadata.People
.Where(p => p.Role == PersonRole.Publisher); .Where(p => p.Role == PersonRole.Publisher);
Assert.Equal(3, publishers.Count()); Assert.Equal(3, publishers.Count());
} }
/// <summary> /// <summary>

View File

@ -12,11 +12,6 @@
<LangVersion>latestmajor</LangVersion> <LangVersion>latestmajor</LangVersion>
</PropertyGroup> </PropertyGroup>
<!-- Moved to GA -->
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<!-- <Delete Files="../openapi.json" />-->
<!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
<!-- </Target>-->
<PropertyGroup Condition=" '$(Configuration)' == 'Release' "> <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols> <DebugSymbols>false</DebugSymbols>

View File

@ -7,6 +7,7 @@ using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Services; using API.Services;
using API.Services.Plus; using API.Services.Plus;
using EasyCaching.Core;
using Hangfire; using Hangfire;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -21,7 +22,8 @@ public class LicenseController(
ILogger<LicenseController> logger, ILogger<LicenseController> logger,
ILicenseService licenseService, ILicenseService licenseService,
ILocalizationService localizationService, ILocalizationService localizationService,
ITaskScheduler taskScheduler) ITaskScheduler taskScheduler,
IEasyCachingProviderFactory cachingProviderFactory)
: BaseApiController : BaseApiController
{ {
/// <summary> /// <summary>
@ -32,8 +34,13 @@ public class LicenseController(
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
public async Task<ActionResult<bool>> HasValidLicense(bool forceCheck = false) public async Task<ActionResult<bool>> HasValidLicense(bool forceCheck = false)
{ {
var result = await licenseService.HasActiveLicense(forceCheck); var result = await licenseService.HasActiveLicense(forceCheck);
if (result)
var licenseInfoProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
var cacheValue = await licenseInfoProvider.GetAsync<bool>(LicenseService.CacheKey);
if (result && !cacheValue.IsNull && !cacheValue.Value)
{ {
await taskScheduler.ScheduleKavitaPlusTasks(); await taskScheduler.ScheduleKavitaPlusTasks();
} }

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Interfaces; using API.Entities.Interfaces;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -101,4 +102,24 @@ public class SeriesMetadata : IHasConcurrencyToken
{ {
RowVersion++; RowVersion++;
} }
/// <summary>
/// Any People in this Role present
/// </summary>
/// <param name="role"></param>
/// <returns></returns>
public bool AnyOfRole(PersonRole role)
{
return People.Any(p => p.Role == role);
}
/// <summary>
/// Are all instances of the role from Kavita+
/// </summary>
/// <param name="role"></param>
/// <returns></returns>
public bool AllKavitaPlus(PersonRole role)
{
return People.Where(p => p.Role == role).All(p => p.KavitaPlusConnection);
}
} }

View File

@ -295,7 +295,16 @@ public class ExternalMetadataService : IExternalMetadataService
if (data == null) return _defaultReturn; if (data == null) return _defaultReturn;
// Get from Kavita+ API the Full Series metadata with rec/rev and cache to ExternalMetadata tables // 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;
}
} }
/// <summary> /// <summary>
@ -314,38 +323,49 @@ public class ExternalMetadataService : IExternalMetadataService
_unitOfWork.SeriesRepository.Update(series); _unitOfWork.SeriesRepository.Update(series);
// Refetch metadata with a Direct lookup // Refetch metadata with a Direct lookup
var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type, new PlusSeriesRequestDto() try
{ {
AniListId = anilistId, var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type,
MalId = malId, new PlusSeriesRequestDto()
SeriesName = series.Name // Required field, not used since AniList/Mal Id are passed {
}); AniListId = anilistId,
MalId = malId,
SeriesName = series.Name // Required field, not used since AniList/Mal Id are passed
});
if (metadata.Series == null) if (metadata.Series == null)
{ {
_logger.LogError("Unable to Match {SeriesName} with Kavita+ Series AniList Id: {AniListId}", series.Name, anilistId); _logger.LogError("Unable to Match {SeriesName} with Kavita+ Series AniList Id: {AniListId}",
return; 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);
} }
/// <summary> /// <summary>
/// Sets a series to Dont Match and removes all previously cached /// Sets a series to Don't Match and removes all previously cached
/// </summary> /// </summary>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
public async Task UpdateSeriesDontMatch(int seriesId, bool dontMatch) 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); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
if (series == null) return _defaultReturn; if (series == null)
{
return _defaultReturn;
}
try try
{ {
@ -417,7 +440,7 @@ public class ExternalMetadataService : IExternalMetadataService
// Recommendations // Recommendations
externalSeriesMetadata.ExternalRecommendations ??= new List<ExternalRecommendation>(); externalSeriesMetadata.ExternalRecommendations ??= [];
var recs = await ProcessRecommendations(libraryType, result.Recommendations, externalSeriesMetadata); var recs = await ProcessRecommendations(libraryType, result.Recommendations, externalSeriesMetadata);
var extRatings = externalSeriesMetadata.ExternalRatings var extRatings = externalSeriesMetadata.ExternalRatings
@ -437,11 +460,19 @@ public class ExternalMetadataService : IExternalMetadataService
{ {
externalSeriesMetadata.Series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); externalSeriesMetadata.Series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId); try
if (madeMetadataModification)
{ {
_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 // WriteExternalMetadataToSeries will commit but not always
@ -466,13 +497,27 @@ public class ExternalMetadataService : IExternalMetadataService
} }
catch (FlurlHttpException ex) catch (FlurlHttpException ex)
{ {
var errorMessage = await ex.GetResponseStringAsync();
// Trim quotes if the response is a JSON string
errorMessage = errorMessage.Trim('"');
if (ex.StatusCode == 500) if (ex.StatusCode == 500)
{ {
return _defaultReturn; return _defaultReturn;
} }
if (ex.StatusCode == 400 && errorMessage.Contains("Too many Requests"))
{
throw new KavitaException("Too many requests, slow down");
}
} }
catch (Exception ex) 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+"); _logger.LogError(ex, "Unable to fetch external series metadata from Kavita+");
} }
@ -1079,10 +1124,18 @@ public class ExternalMetadataService : IExternalMetadataService
var aniListId = ScrobblingService.ExtractId<int?>(staff.Url, ScrobblingService.AniListStaffWebsite); var aniListId = ScrobblingService.ExtractId<int?>(staff.Url, ScrobblingService.AniListStaffWebsite);
if (aniListId is null or <= 0) continue; if (aniListId is null or <= 0) continue;
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value); 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); 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);
}
} }
} }

View File

@ -44,7 +44,10 @@ public class LicenseService(
{ {
private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8); private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8);
public const string Cron = "0 */9 * * *"; public const string Cron = "0 */9 * * *";
private const string CacheKey = "license"; /// <summary>
/// Cache key for if license is valid or not
/// </summary>
public const string CacheKey = "license";
private const string LicenseInfoCacheKey = "license-info"; private const string LicenseInfoCacheKey = "license-info";

View File

@ -19,7 +19,6 @@ using API.SignalR;
using Flurl.Http; using Flurl.Http;
using Hangfire; using Hangfire;
using Kavita.Common; using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers; using Kavita.Common.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -77,7 +76,7 @@ public class ScrobblingService : IScrobblingService
public const string AniListCharacterWebsite = "https://anilist.co/character/"; public const string AniListCharacterWebsite = "https://anilist.co/character/";
private static readonly IDictionary<string, int> WeblinkExtractionMap = new Dictionary<string, int>() private static readonly Dictionary<string, int> WeblinkExtractionMap = new Dictionary<string, int>()
{ {
{AniListWeblinkWebsite, 0}, {AniListWeblinkWebsite, 0},
{MalWeblinkWebsite, 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 const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90)
private static readonly IList<ScrobbleProvider> BookProviders = new List<ScrobbleProvider>() private static readonly IList<ScrobbleProvider> BookProviders = [];
{ private static readonly IList<ScrobbleProvider> LightNovelProviders =
}; [
private static readonly IList<ScrobbleProvider> LightNovelProviders = new List<ScrobbleProvider>()
{
ScrobbleProvider.AniList ScrobbleProvider.AniList
}; ];
private static readonly IList<ScrobbleProvider> ComicProviders = new List<ScrobbleProvider>(); private static readonly IList<ScrobbleProvider> ComicProviders = [];
private static readonly IList<ScrobbleProvider> MangaProviders = new List<ScrobbleProvider>() private static readonly IList<ScrobbleProvider> MangaProviders = (List<ScrobbleProvider>)
{ [ScrobbleProvider.AniList];
ScrobbleProvider.AniList
};
private const string UnknownSeriesErrorMessage = "Series cannot be matched for Scrobbling"; 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 // Create a new ExternalMetadata entry to indicate that this is not matchable
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(evt.SeriesId, SeriesIncludes.ExternalMetadata); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(evt.SeriesId, SeriesIncludes.ExternalMetadata);
if (series.ExternalSeriesMetadata == null) if (series == null) return 0;
{
series.ExternalSeriesMetadata = new ExternalSeriesMetadata() {SeriesId = evt.SeriesId}; series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata() {SeriesId = evt.SeriesId};
} series.IsBlacklisted = true;
series!.IsBlacklisted = true;
_unitOfWork.SeriesRepository.Update(series); _unitOfWork.SeriesRepository.Update(series);
_unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError()
@ -824,6 +818,7 @@ public class ScrobblingService : IScrobblingService
readEvt.AppUser.Id); readEvt.AppUser.Id);
_unitOfWork.ScrobbleRepository.Update(readEvt); _unitOfWork.ScrobbleRepository.Update(readEvt);
} }
progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, async evt => new ScrobbleDto() progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, async evt => new ScrobbleDto()
{ {
Format = evt.Format, Format = evt.Format,
@ -888,9 +883,9 @@ public class ScrobblingService : IScrobblingService
await _unitOfWork.CommitAsync(); 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; return;
} }
@ -986,7 +981,7 @@ public class ScrobblingService : IScrobblingService
{ {
if (ex.Message.Contains("Access token is invalid")) 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.IsErrored = true;
evt.ErrorDetails = AccessTokenErrorMessage; evt.ErrorDetails = AccessTokenErrorMessage;
_unitOfWork.ScrobbleRepository.Update(evt); _unitOfWork.ScrobbleRepository.Update(evt);
@ -1106,7 +1101,7 @@ public class ScrobblingService : IScrobblingService
return null; // Unsupported website return null; // Unsupported website
} }
if (id == null) if (Equals(id, default(T)))
{ {
throw new ArgumentNullException(nameof(id), "ID cannot be null."); throw new ArgumentNullException(nameof(id), "ID cannot be null.");
} }
@ -1140,7 +1135,7 @@ public class ScrobblingService : IScrobblingService
} }
catch (Exception ex) 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); userRateLimits.Add(user.Id, 0);
} }

View File

@ -331,84 +331,123 @@ public class ProcessSeries : IProcessSeries
} }
#region PeopleAndTagsAndGenres #region PeopleAndTagsAndGenres
if (!series.Metadata.WriterLocked) if (!series.Metadata.WriterLocked)
{ {
var personSw = Stopwatch.StartNew(); var personSw = Stopwatch.StartNew();
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList();
await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); if (ShouldUpdatePeopleForRole(series, 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); {
} 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) if (!series.Metadata.ColoristLocked)
{ {
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Colorist)).ToList(); 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) if (!series.Metadata.PublisherLocked)
{ {
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Publisher)).ToList(); 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) if (!series.Metadata.CoverArtistLocked)
{ {
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.CoverArtist)).ToList(); 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) if (!series.Metadata.CharacterLocked)
{ {
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Character)).ToList(); 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) if (!series.Metadata.EditorLocked)
{ {
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Editor)).ToList(); 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) if (!series.Metadata.InkerLocked)
{ {
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Inker)).ToList(); 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) if (!series.Metadata.ImprintLocked)
{ {
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Imprint)).ToList(); 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) if (!series.Metadata.TeamLocked)
{ {
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Team)).ToList(); 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(); 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(); 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(); 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(); 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
} }
/// <summary>
/// Ensure that we don't overwrite Person metadata when all metadata is coming from Kavita+ metadata match functionality
/// </summary>
/// <param name="series"></param>
/// <param name="chapterPeople"></param>
/// <param name="role"></param>
/// <returns></returns>
private static bool ShouldUpdatePeopleForRole(Series series, List<ChapterPeople> 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) private async Task UpdateCollectionTags(Series series, Chapter firstChapter)
{ {
// Get the default admin to associate these tags to // 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)) .Where(v => v.MaxNumber.IsNot(Parser.Parser.SpecialVolumeNumber))
.ToList(); .ToList();
var maxVolume = (int) (nonSpecialVolumes.Any() ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); var maxVolume = (int)(nonSpecialVolumes.Any() ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0);
var maxChapter = (int) chapters.Max(c => c.MaxNumber); var maxChapter = (int)chapters.Max(c => c.MaxNumber);
// Single books usually don't have a number in their Range (filename) // Single books usually don't have a number in their Range (filename)
if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1) if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1)