Kavita+ Comic Metadata Matching (#3740)

This commit is contained in:
Joe Milazzo 2025-04-25 07:26:48 -06:00 committed by GitHub
parent 4521965315
commit ed154e4768
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 4207 additions and 98 deletions

View file

@ -649,13 +649,13 @@ public class SeriesController : BaseApiController
/// <summary>
/// This will perform the fix match
/// </summary>
/// <param name="aniListId"></param>
/// <param name="match"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("update-match")]
public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int aniListId, [FromQuery] long? malId)
public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int? aniListId, [FromQuery] long? malId, [FromQuery] int? cbrId)
{
BackgroundJob.Enqueue(() => _externalMetadataService.FixSeriesMatch(seriesId, aniListId, malId));
BackgroundJob.Enqueue(() => _externalMetadataService.FixSeriesMatch(seriesId, aniListId, malId, cbrId));
return Ok();
}

View file

@ -13,4 +13,5 @@ internal class SeriesDetailPlusApiDto
public ExternalSeriesDetailDto? Series { get; set; }
public int? AniListId { get; set; }
public long? MalId { get; set; }
public int? CbrId { get; set; }
}

View file

@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using API.DTOs.SeriesDetail;
namespace API.DTOs.KavitaPlus.Metadata;
/// <summary>
/// Information about an individual issue/chapter/book from Kavita+
/// </summary>
public class ExternalChapterDto
{
public string Title { get; set; }
public string IssueNumber { get; set; }
public decimal? CriticRating { get; set; }
public decimal? UserRating { get; set; }
public string? Summary { get; set; }
public IList<string>? Writers { get; set; }
public IList<string>? Artists { get; set; }
public DateTime? ReleaseDate { get; set; }
public string? Publisher { get; set; }
public string? CoverImageUrl { get; set; }
public string? IssueUrl { get; set; }
public IList<UserReviewDto> CriticReviews { get; set; }
public IList<UserReviewDto> UserReviews { get; set; }
}

View file

@ -15,6 +15,7 @@ public class ExternalSeriesDetailDto
public string Name { get; set; }
public int? AniListId { get; set; }
public long? MALId { get; set; }
public int? CbrId { get; set; }
public IList<string> Synonyms { get; set; } = [];
public PlusMediaFormat PlusMediaFormat { get; set; }
public string? SiteUrl { get; set; }
@ -33,5 +34,13 @@ public class ExternalSeriesDetailDto
public IList<SeriesRelationship>? Relations { get; set; } = [];
public IList<SeriesCharacter>? Characters { get; set; } = [];
#region Comic Only
public string? Publisher { get; set; }
/// <summary>
/// Only from CBR for <see cref="ScrobbleProvider.Cbr"/>. Full metadata about issues
/// </summary>
public IList<ExternalChapterDto>? ChapterDtos { get; set; }
#endregion
}

View file

@ -43,6 +43,29 @@ public class MetadataSettingsDto
/// </summary>
public bool EnableCoverImage { get; set; }
#region Chapter Metadata
/// <summary>
/// Allow Summary to be set within Chapter/Issue
/// </summary>
public bool EnableChapterSummary { get; set; }
/// <summary>
/// Allow Release Date to be set within Chapter/Issue
/// </summary>
public bool EnableChapterReleaseDate { get; set; }
/// <summary>
/// Allow Title to be set within Chapter/Issue
/// </summary>
public bool EnableChapterTitle { get; set; }
/// <summary>
/// Allow Publisher to be set within Chapter/Issue
/// </summary>
public bool EnableChapterPublisher { get; set; }
/// <summary>
/// Allow setting the cover image for the Chapter/Issue
/// </summary>
public bool EnableChapterCoverImage { get; set; }
#endregion
// Need to handle the Genre/tags stuff
public bool EnableGenres { get; set; } = true;
public bool EnableTags { get; set; } = true;

View file

@ -10,6 +10,10 @@ public record PlusSeriesRequestDto
public long? MalId { get; set; }
public string? GoogleBooksId { get; set; }
public string? MangaDexId { get; set; }
/// <summary>
/// ComicBookRoundup Id
/// </summary>
public int? CbrId { get; set; }
public string SeriesName { get; set; }
public string? AltSeriesName { get; set; }
public PlusMediaFormat MediaFormat { get; set; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class KavitaPlusCBR : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "EnableChapterCoverImage",
table: "MetadataSettings",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "EnableChapterPublisher",
table: "MetadataSettings",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "EnableChapterReleaseDate",
table: "MetadataSettings",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "EnableChapterSummary",
table: "MetadataSettings",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "EnableChapterTitle",
table: "MetadataSettings",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "CbrId",
table: "ExternalSeriesMetadata",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "KavitaPlusConnection",
table: "ChapterPeople",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "OrderWeight",
table: "ChapterPeople",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EnableChapterCoverImage",
table: "MetadataSettings");
migrationBuilder.DropColumn(
name: "EnableChapterPublisher",
table: "MetadataSettings");
migrationBuilder.DropColumn(
name: "EnableChapterReleaseDate",
table: "MetadataSettings");
migrationBuilder.DropColumn(
name: "EnableChapterSummary",
table: "MetadataSettings");
migrationBuilder.DropColumn(
name: "EnableChapterTitle",
table: "MetadataSettings");
migrationBuilder.DropColumn(
name: "CbrId",
table: "ExternalSeriesMetadata");
migrationBuilder.DropColumn(
name: "KavitaPlusConnection",
table: "ChapterPeople");
migrationBuilder.DropColumn(
name: "OrderWeight",
table: "ChapterPeople");
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -1429,6 +1429,9 @@ namespace API.Data.Migrations
b.Property<int>("AverageExternalRating")
.HasColumnType("INTEGER");
b.Property<int>("CbrId")
.HasColumnType("INTEGER");
b.Property<string>("GoogleBooksId")
.HasColumnType("TEXT");
@ -1645,6 +1648,21 @@ namespace API.Data.Migrations
b.Property<string>("Blacklist")
.HasColumnType("TEXT");
b.Property<bool>("EnableChapterCoverImage")
.HasColumnType("INTEGER");
b.Property<bool>("EnableChapterPublisher")
.HasColumnType("INTEGER");
b.Property<bool>("EnableChapterReleaseDate")
.HasColumnType("INTEGER");
b.Property<bool>("EnableChapterSummary")
.HasColumnType("INTEGER");
b.Property<bool>("EnableChapterTitle")
.HasColumnType("INTEGER");
b.Property<bool>("EnableCoverImage")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@ -1707,6 +1725,12 @@ namespace API.Data.Migrations
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<bool>("KavitaPlusConnection")
.HasColumnType("INTEGER");
b.Property<int>("OrderWeight")
.HasColumnType("INTEGER");
b.HasKey("ChapterId", "PersonId", "Role");
b.HasIndex("PersonId");

View file

@ -47,6 +47,7 @@ public interface IChapterRepository
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter);
IEnumerable<Chapter> GetChaptersForSeries(int seriesId);
Task<IList<Chapter>> GetAllChaptersForSeries(int seriesId);
}
public class ChapterRepository : IChapterRepository
{
@ -298,4 +299,15 @@ public class ChapterRepository : IChapterRepository
.Include(c => c.Volume)
.AsEnumerable();
}
public async Task<IList<Chapter>> GetAllChaptersForSeries(int seriesId)
{
return await _context.Chapter
.Where(c => c.Volume.SeriesId == seriesId)
.OrderBy(c => c.SortOrder)
.Include(c => c.Volume)
.Include(c => c.People)
.ThenInclude(cp => cp.Person)
.ToListAsync();
}
}

View file

@ -157,8 +157,8 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
.OrderByDescending(r => r.Score);
}
IEnumerable<RatingDto> ratings = new List<RatingDto>();
if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Any())
IEnumerable<RatingDto> ratings = [];
if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Count != 0)
{
ratings = seriesDetailDto.ExternalRatings
.Select(r => _mapper.Map<RatingDto>(r));

View file

@ -312,6 +312,11 @@ public static class Seed
EnableLocalizedName = false,
FirstLastPeopleNaming = true,
EnableCoverImage = true,
EnableChapterTitle = false,
EnableChapterSummary = true,
EnableChapterPublisher = true,
EnableChapterCoverImage = false,
EnableChapterReleaseDate = true,
PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]
};
await context.MetadataSettings.AddAsync(existing);

View file

@ -234,4 +234,25 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
PrimaryColor = string.Empty;
SecondaryColor = string.Empty;
}
public bool IsPersonRoleLocked(PersonRole role)
{
return role switch
{
PersonRole.Character => CharacterLocked,
PersonRole.Writer => WriterLocked,
PersonRole.Penciller => PencillerLocked,
PersonRole.Inker => InkerLocked,
PersonRole.Colorist => ColoristLocked,
PersonRole.Letterer => LettererLocked,
PersonRole.CoverArtist => CoverArtistLocked,
PersonRole.Editor => EditorLocked,
PersonRole.Publisher => PublisherLocked,
PersonRole.Translator => TranslatorLocked,
PersonRole.Imprint => ImprintLocked,
PersonRole.Team => TeamLocked,
PersonRole.Location => LocationLocked,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
};
}
}

View file

@ -26,6 +26,7 @@ public class ExternalSeriesMetadata
public int AverageExternalRating { get; set; } = -1;
public int AniListId { get; set; }
public int CbrId { get; set; }
public long MalId { get; set; }
public string GoogleBooksId { get; set; }

View file

@ -5,6 +5,7 @@
/// </summary>
public enum MetadataSettingField
{
#region Series Metadata
Summary = 1,
PublicationStatus = 2,
StartDate = 3,
@ -13,5 +14,18 @@ public enum MetadataSettingField
LocalizedName = 6,
Covers = 7,
AgeRating = 8,
People = 9
People = 9,
#endregion
#region Chapter Metadata
ChapterTitle = 10,
ChapterSummary = 11,
ChapterReleaseDate = 12,
ChapterPublisher = 13,
ChapterCovers = 14,
#endregion
}

View file

@ -14,6 +14,8 @@ public class MetadataSettings
/// </summary>
public bool Enabled { get; set; }
#region Series Metadata
/// <summary>
/// Allow the Summary to be written
/// </summary>
@ -42,6 +44,30 @@ public class MetadataSettings
/// Allow setting the cover image
/// </summary>
public bool EnableCoverImage { get; set; }
#endregion
#region Chapter Metadata
/// <summary>
/// Allow Summary to be set within Chapter/Issue
/// </summary>
public bool EnableChapterSummary { get; set; }
/// <summary>
/// Allow Release Date to be set within Chapter/Issue
/// </summary>
public bool EnableChapterReleaseDate { get; set; }
/// <summary>
/// Allow Title to be set within Chapter/Issue
/// </summary>
public bool EnableChapterTitle { get; set; }
/// <summary>
/// Allow Publisher to be set within Chapter/Issue
/// </summary>
public bool EnableChapterPublisher { get; set; }
/// <summary>
/// Allow setting the cover image for the Chapter/Issue
/// </summary>
public bool EnableChapterCoverImage { get; set; }
#endregion
// Need to handle the Genre/tags stuff
public bool EnableGenres { get; set; } = true;

View file

@ -10,5 +10,14 @@ public class ChapterPeople
public int PersonId { get; set; }
public virtual Person Person { get; set; }
/// <summary>
/// The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches
/// </summary>
public bool KavitaPlusConnection { get; set; }
/// <summary>
/// A weight that allows lower numbers to sort first
/// </summary>
public int OrderWeight { get; set; }
public required PersonRole Role { get; set; }
}

View file

@ -50,7 +50,7 @@ public interface IExternalMetadataService
Task<IList<MalStackDto>> GetStacksForUser(int userId);
Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto);
Task FixSeriesMatch(int seriesId, int anilistId, long? malId);
Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId);
Task UpdateSeriesDontMatch(int seriesId, bool dontMatch);
Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId);
}
@ -66,7 +66,7 @@ public class ExternalMetadataService : IExternalMetadataService
private readonly ICoverDbService _coverDbService;
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30);
public static readonly HashSet<LibraryType> NonEligibleLibraryTypes =
[LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine];
[LibraryType.Comic, LibraryType.Book, LibraryType.Image];
private readonly SeriesDetailPlusDto _defaultReturn = new()
{
Series = null,
@ -203,7 +203,7 @@ public class ExternalMetadataService : IExternalMetadataService
{
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata);
SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata | SeriesIncludes.Library);
if (series == null) return [];
var potentialAnilistId = ScrobblingService.ExtractId<int?>(dto.Query, ScrobblingService.AniListWeblinkWebsite);
@ -217,7 +217,7 @@ public class ExternalMetadataService : IExternalMetadataService
var matchRequest = new MatchSeriesRequestDto()
{
Format = series.Format == MangaFormat.Epub ? PlusMediaFormat.LightNovel : PlusMediaFormat.Manga,
Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
Query = dto.Query,
SeriesName = series.Name,
AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(),
@ -319,8 +319,10 @@ public class ExternalMetadataService : IExternalMetadataService
/// This will override any sort of matching that was done prior and force it to be what the user Selected
/// </summary>
/// <param name="seriesId"></param>
/// <param name="anilistId"></param>
public async Task FixSeriesMatch(int seriesId, int anilistId, long? malId)
/// <param name="aniListId"></param>
/// <param name="malId"></param>
/// <param name="cbrId"></param>
public async Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
if (series == null) return;
@ -336,15 +338,17 @@ public class ExternalMetadataService : IExternalMetadataService
var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type,
new PlusSeriesRequestDto()
{
AniListId = anilistId,
AniListId = aniListId,
MalId = malId,
CbrId = cbrId,
MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
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);
_logger.LogError("Unable to Match {SeriesName} with Kavita+ Series with Id: {AniListId}/{MalId}/{CbrId}",
series.Name, aniListId, malId, cbrId);
return;
}
@ -428,8 +432,7 @@ public class ExternalMetadataService : IExternalMetadataService
result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
.WithKavitaPlusHeaders(license, token)
.PostJsonAsync(data)
.ReceiveJson<
SeriesDetailPlusApiDto>(); // This returns an AniListSeries and Match returns ExternalSeriesDto
.ReceiveJson<SeriesDetailPlusApiDto>(); // This returns an AniListSeries and Match returns ExternalSeriesDto
}
catch (FlurlHttpException ex)
{
@ -482,6 +485,7 @@ public class ExternalMetadataService : IExternalMetadataService
{
var rating = _mapper.Map<ExternalRating>(r);
rating.SeriesId = externalSeriesMetadata.SeriesId;
rating.ProviderUrl = r.ProviderUrl;
return rating;
}).ToList();
@ -500,6 +504,7 @@ public class ExternalMetadataService : IExternalMetadataService
if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value;
if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value;
if (result.CbrId.HasValue) externalSeriesMetadata.CbrId = result.CbrId.Value;
// If there is metadata and the user has metadata download turned on
var madeMetadataModification = false;
@ -622,6 +627,8 @@ public class ExternalMetadataService : IExternalMetadataService
madeModification = await UpdateRelationships(series, settings, externalMetadata.Relations, defaultAdmin) || madeModification;
madeModification = await UpdateCoverImage(series, settings, externalMetadata) || madeModification;
madeModification = await UpdateChapters(series, settings, externalMetadata) || madeModification;
return madeModification;
}
@ -848,7 +855,6 @@ public class ExternalMetadataService : IExternalMetadataService
}
}
// Download the image and save it
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
@ -1044,6 +1050,199 @@ public class ExternalMetadataService : IExternalMetadataService
return false;
}
private async Task<bool> UpdateChapters(Series series, MetadataSettingsDto settings,
ExternalSeriesDetailDto externalMetadata)
{
if (externalMetadata.PlusMediaFormat != PlusMediaFormat.Comic) return false;
if (externalMetadata.ChapterDtos == null || externalMetadata.ChapterDtos.Count == 0) return false;
// Get all volumes and chapters
var madeModification = false;
var allChapters = await _unitOfWork.ChapterRepository.GetAllChaptersForSeries(series.Id);
var matchedChapters = allChapters
.Join(
externalMetadata.ChapterDtos,
chapter => chapter.Range,
dto => dto.IssueNumber,
(chapter, dto) => (chapter, dto) // Create a tuple of matched pairs
)
.ToList();
foreach (var (chapter, potentialMatch) in matchedChapters)
{
_logger.LogDebug("Updating {ChapterNumber} with metadata", chapter.Range);
// Write the metadata
madeModification = UpdateChapterTitle(chapter, settings, potentialMatch.Title, series.Name) || madeModification;
madeModification = UpdateChapterSummary(chapter, settings, potentialMatch.Summary) || madeModification;
madeModification = UpdateChapterReleaseDate(chapter, settings, potentialMatch.ReleaseDate) || madeModification;
madeModification = await UpdateChapterPublisher(chapter, settings, potentialMatch.Publisher) || madeModification;
madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.CoverArtist, potentialMatch.Artists) || madeModification;
madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification;
madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification;
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
}
return madeModification;
}
private static bool UpdateChapterSummary(Chapter chapter, MetadataSettingsDto settings, string? summary)
{
if (!settings.EnableChapterSummary) return false;
if (string.IsNullOrEmpty(summary)) return false;
if (chapter.SummaryLocked && !settings.HasOverride(MetadataSettingField.ChapterSummary))
{
return false;
}
if (!string.IsNullOrWhiteSpace(summary) && !settings.HasOverride(MetadataSettingField.ChapterSummary))
{
return false;
}
chapter.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(summary));
return true;
}
private static bool UpdateChapterTitle(Chapter chapter, MetadataSettingsDto settings, string? title, string seriesName)
{
if (!settings.EnableChapterTitle) return false;
if (string.IsNullOrEmpty(title)) return false;
if (chapter.TitleNameLocked && !settings.HasOverride(MetadataSettingField.ChapterTitle))
{
return false;
}
if (!title.Contains(seriesName) && !settings.HasOverride(MetadataSettingField.ChapterTitle))
{
return false;
}
chapter.TitleName = title;
return true;
}
private static bool UpdateChapterReleaseDate(Chapter chapter, MetadataSettingsDto settings, DateTime? releaseDate)
{
if (!settings.EnableChapterReleaseDate) return false;
if (releaseDate == null || releaseDate == DateTime.MinValue) return false;
if (chapter.ReleaseDateLocked && !settings.HasOverride(MetadataSettingField.ChapterReleaseDate))
{
return false;
}
if (!settings.HasOverride(MetadataSettingField.ChapterReleaseDate))
{
return false;
}
chapter.ReleaseDate = releaseDate.Value;
return true;
}
private async Task<bool> UpdateChapterPublisher(Chapter chapter, MetadataSettingsDto settings, string? publisher)
{
if (!settings.EnableChapterPublisher) return false;
if (string.IsNullOrEmpty(publisher)) return false;
if (chapter.PublisherLocked && !settings.HasOverride(MetadataSettingField.ChapterPublisher))
{
return false;
}
if (!string.IsNullOrWhiteSpace(publisher) && !settings.HasOverride(MetadataSettingField.ChapterPublisher))
{
return false;
}
return await UpdateChapterPeople(chapter, settings, PersonRole.Publisher, [publisher]);
}
private async Task<bool> UpdateChapterCoverImage(Chapter chapter, MetadataSettingsDto settings, string? coverUrl)
{
if (!settings.EnableChapterCoverImage) return false;
if (string.IsNullOrEmpty(coverUrl)) return false;
if (chapter.CoverImageLocked && !settings.HasOverride(MetadataSettingField.ChapterCovers))
{
return false;
}
if (string.IsNullOrEmpty(coverUrl))
{
return false;
}
await DownloadChapterCovers(chapter, coverUrl);
return true;
}
private async Task<bool> UpdateChapterPeople(Chapter chapter, MetadataSettingsDto settings, PersonRole role, IList<string>? staff)
{
if (!settings.EnablePeople) return false;
if (staff?.Count == 0) return false;
if (chapter.IsPersonRoleLocked(role) && !settings.HasOverride(MetadataSettingField.People))
{
return false;
}
if (!settings.IsPersonAllowed(role) && role != PersonRole.Publisher)
{
return false;
}
chapter.People ??= [];
var people = staff!
.Select(w => new PersonDto()
{
Name = w,
})
.Concat(chapter.People
.Where(p => p.Role == role)
.Where(p => !p.KavitaPlusConnection)
.Select(p => _mapper.Map<PersonDto>(p.Person))
)
.DistinctBy(p => Parser.Normalize(p.Name))
.ToList();
await PersonHelper.UpdateChapterPeopleAsync(chapter, staff, role, _unitOfWork);
foreach (var person in chapter.People.Where(p => p.Role == role))
{
var meta = people.FirstOrDefault(c => c.Name == person.Person.Name);
person.OrderWeight = 0;
if (meta != null)
{
person.KavitaPlusConnection = true;
}
}
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
return true;
}
private async Task<bool> UpdateCoverImage(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata)
{
if (!settings.EnableCoverImage) return false;
@ -1166,6 +1365,18 @@ public class ExternalMetadataService : IExternalMetadataService
}
}
private async Task DownloadChapterCovers(Chapter chapter, string coverUrl)
{
try
{
await _coverDbService.SetChapterCoverByUrl(chapter, coverUrl, false, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception downloading cover image for Chapter {ChapterName} ({SeriesId})", chapter.Range, chapter.Id);
}
}
private async Task DownloadAndSetPersonCovers(List<SeriesStaffDto> people)
{
foreach (var staff in people)

View file

@ -263,6 +263,7 @@ public class LicenseService(
if (cacheValue.HasValue) return cacheValue.Value;
}
// TODO: If info.IsCancelled && notActive, let's remove the license so we aren't constantly checking
try
{

View file

@ -38,6 +38,9 @@ public enum ScrobbleProvider
Kavita = 0,
AniList = 1,
Mal = 2,
[Obsolete]
GoogleBooks = 3,
Cbr = 4
}
public interface IScrobblingService

View file

@ -65,6 +65,12 @@ public class SettingsService : ISettingsService
existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming;
existingMetadataSetting.EnableCoverImage = dto.EnableCoverImage;
existingMetadataSetting.EnableChapterPublisher = dto.EnableChapterPublisher;
existingMetadataSetting.EnableChapterSummary = dto.EnableChapterSummary;
existingMetadataSetting.EnableChapterTitle = dto.EnableChapterTitle;
existingMetadataSetting.EnableChapterReleaseDate = dto.EnableChapterReleaseDate;
existingMetadataSetting.EnableChapterCoverImage = dto.EnableChapterCoverImage;
existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? [];
existingMetadataSetting.Blacklist = (dto.Blacklist ?? []).Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? [];

View file

@ -32,6 +32,7 @@ public interface ICoverDbService
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url);
Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false);
Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false);
Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false);
}
@ -580,6 +581,51 @@ public class CoverDbService : ICoverDbService
}
}
public async Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false)
{
if (!string.IsNullOrEmpty(url))
{
var filePath = await CreateThumbnail(url, $"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}", fromBase64);
if (!string.IsNullOrEmpty(filePath))
{
// Additional check to see if downloaded image is similar and we have a higher resolution
if (chooseBetterImage && !string.IsNullOrEmpty(chapter.CoverImage))
{
try
{
var betterImage = Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage)
.GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!;
filePath = Path.GetFileName(betterImage);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue trying to choose a better cover image for Chapter: {FileName} ({ChapterId})", chapter.Range, chapter.Id);
}
}
chapter.CoverImage = filePath;
chapter.CoverImageLocked = true;
_imageService.UpdateColorScape(chapter);
_unitOfWork.ChapterRepository.Update(chapter);
}
}
else
{
chapter.CoverImage = null;
chapter.CoverImageLocked = false;
_imageService.UpdateColorScape(chapter);
_unitOfWork.ChapterRepository.Update(chapter);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), false);
}
}
private async Task<string> CreateThumbnail(string url, string filename, bool fromBase64 = true)
{
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();

View file

@ -977,26 +977,26 @@ public class ProcessSeries : IProcessSeries
chapter.ReleaseDate = new DateTime(comicInfo.Year, month, day);
}
if (!chapter.ColoristLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Colorist))
{
var people = TagHelper.GetTagValues(comicInfo.Colorist);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Colorist);
}
if (!chapter.CharacterLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Character))
{
var people = TagHelper.GetTagValues(comicInfo.Characters);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Character);
}
if (!chapter.TranslatorLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Translator))
{
var people = TagHelper.GetTagValues(comicInfo.Translator);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Translator);
}
if (!chapter.WriterLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Writer))
{
var personSw = Stopwatch.StartNew();
var people = TagHelper.GetTagValues(comicInfo.Writer);
@ -1004,55 +1004,55 @@ public class ProcessSeries : IProcessSeries
_logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Chapter: {File} for {Count} people", personSw.ElapsedMilliseconds, chapter.Files.First().FileName, people.Count);
}
if (!chapter.EditorLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Editor))
{
var people = TagHelper.GetTagValues(comicInfo.Editor);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Editor);
}
if (!chapter.InkerLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Inker))
{
var people = TagHelper.GetTagValues(comicInfo.Inker);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Inker);
}
if (!chapter.LettererLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Letterer))
{
var people = TagHelper.GetTagValues(comicInfo.Letterer);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Letterer);
}
if (!chapter.PencillerLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Penciller))
{
var people = TagHelper.GetTagValues(comicInfo.Penciller);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Penciller);
}
if (!chapter.CoverArtistLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.CoverArtist))
{
var people = TagHelper.GetTagValues(comicInfo.CoverArtist);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.CoverArtist);
}
if (!chapter.PublisherLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Publisher))
{
var people = TagHelper.GetTagValues(comicInfo.Publisher);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Publisher);
}
if (!chapter.ImprintLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Imprint))
{
var people = TagHelper.GetTagValues(comicInfo.Imprint);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Imprint);
}
if (!chapter.TeamLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Team))
{
var people = TagHelper.GetTagValues(comicInfo.Teams);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Team);
}
if (!chapter.LocationLocked)
if (!chapter.IsPersonRoleLocked(PersonRole.Location))
{
var people = TagHelper.GetTagValues(comicInfo.Locations);
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Location);