Skip to content

Commit

Permalink
Metadata Fixes (#3533)
Browse files Browse the repository at this point in the history
Co-authored-by: Midhun Sudhir <[email protected]>
  • Loading branch information
majora2007 and midhun3301 authored Feb 6, 2025
1 parent 40bbdcb commit bb9621a
Show file tree
Hide file tree
Showing 25 changed files with 151 additions and 56 deletions.
8 changes: 8 additions & 0 deletions API/Data/DataContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using API.DTOs.KavitaPlus.Metadata;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
Expand Down Expand Up @@ -217,13 +218,20 @@ protected override void OnModelCreating(ModelBuilder builder)
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)
);
builder.Entity<MetadataSettings>()
.Property(x => x.Whitelist)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)
);

// Configure one-to-many relationship
builder.Entity<MetadataSettings>()
.HasMany(x => x.FieldMappings)
.WithOne(x => x.MetadataSettings)
.HasForeignKey(x => x.MetadataSettingsId)
.OnDelete(DeleteBehavior.Cascade);

builder.Entity<MetadataSettings>()
.Property(b => b.Enabled)
.HasDefaultValue(true);
Expand Down
1 change: 1 addition & 0 deletions API/Data/Repositories/ExternalSeriesMetadataRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ public async Task<IList<int>> GetAllSeriesIdsWithoutMetadata(int limit)
public async Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter)
{
return await _context.Series
.Include(s => s.Library)
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
.FilterMatchState(filter.MatchStateOption)
.OrderBy(s => s.NormalizedName)
Expand Down
2 changes: 1 addition & 1 deletion API/Data/Seed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ public static async Task SeedMetadataSettings(DataContext context)
EnableTags = false,
EnableGenres = true,
EnableLocalizedName = false,
FirstLastPeopleNaming = false,
FirstLastPeopleNaming = true,
PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]
};
await context.MetadataSettings.AddAsync(existing);
Expand Down
10 changes: 7 additions & 3 deletions API/Extensions/QueryExtensions/QueryableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,15 @@ public static IQueryable<Series> FilterMatchState(this IQueryable<Series> query,
return stateOption switch
{
MatchStateOption.All => query,
MatchStateOption.Matched => query.Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted),
MatchStateOption.NotMatched => query.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted),
MatchStateOption.Matched => query
.Include(s => s.ExternalSeriesMetadata)
.Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted),
MatchStateOption.NotMatched => query.
Include(s => s.ExternalSeriesMetadata)
.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted),
MatchStateOption.Error => query.Where(s => s.IsBlacklisted),
MatchStateOption.DontMatch => query.Where(s => s.DontMatch),
_ => throw new ArgumentOutOfRangeException(nameof(stateOption), stateOption, null)
_ => query
};
}
}
17 changes: 12 additions & 5 deletions API/Helpers/AutoMapperProfiles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,13 @@ public AutoMapperProfiles()
opt.MapFrom(src => src))
.ForMember(dest => dest.IsMatched,
opt =>
opt.MapFrom(src => src.ExternalSeriesMetadata != null && src.ExternalSeriesMetadata.AniListId != 0 && src.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue))
opt.MapFrom(src => src.ExternalSeriesMetadata != null && src.ExternalSeriesMetadata.AniListId != 0
&& src.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue))
.ForMember(dest => dest.ValidUntilUtc,
opt =>
opt.MapFrom(src => src.ExternalSeriesMetadata.ValidUntilUtc));
opt => opt.MapFrom(src =>
src.ExternalSeriesMetadata != null
? src.ExternalSeriesMetadata.ValidUntilUtc
: DateTime.MinValue));


CreateMap<MangaFile, FileExtensionExportDto>();
Expand All @@ -361,10 +364,14 @@ public AutoMapperProfiles()
.ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId))
.ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type));

CreateMap<MetadataFieldMapping, MetadataFieldMappingDto>();

CreateMap<MetadataSettings, MetadataSettingsDto>()
.ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List<string>()))
.ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List<string>()));
CreateMap<MetadataFieldMapping, MetadataFieldMappingDto>();
.ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List<string>()))
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));



}
}
88 changes: 67 additions & 21 deletions API/Services/Plus/ExternalMetadataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ public async Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto)
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata);
if (series == null) return [];

var potentialAnilistId = ScrobblingService.ExtractId<int?>(dto.Query, ScrobblingService.AniListWeblinkWebsite);
var potentialMalId = ScrobblingService.ExtractId<long?>(dto.Query, ScrobblingService.MalWeblinkWebsite);
Expand Down Expand Up @@ -512,7 +513,7 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e
madeModification = true;
}

if (settings.EnableStartDate && externalMetadata.StartDate.HasValue)
if (settings.EnableStartDate && !series.Metadata.ReleaseYearLocked && externalMetadata.StartDate.HasValue)
{
series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year;
madeModification = true;
Expand All @@ -526,7 +527,7 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e
// Process Genres
if (externalMetadata.Genres != null)
{
foreach (var genre in externalMetadata.Genres.Where(g => !settings.Blacklist.Contains(g)))
foreach (var genre in externalMetadata.Genres)
{
// Apply field mappings
var mappedGenre = ApplyFieldMapping(genre, MetadataFieldType.Genre, settings.FieldMappings);
Expand All @@ -537,9 +538,12 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e
}

// Strip blacklisted items from processedGenres
processedGenres = processedGenres.Distinct().Where(g => !settings.Blacklist.Contains(g)).ToList();
processedGenres = processedGenres
.Distinct()
.Where(g => !settings.Blacklist.Contains(g))
.ToList();

if (settings.EnableGenres && processedGenres.Count > 0)
if (settings.EnableGenres && !series.Metadata.GenresLocked && processedGenres.Count > 0)
{
_logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name);
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList();
Expand Down Expand Up @@ -567,13 +571,14 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e
}

// Strip blacklisted items from processedTags
processedTags = processedTags.Distinct()
processedTags = processedTags
.Distinct()
.Where(g => !settings.Blacklist.Contains(g))
.Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g))
.ToList();

// Set the tags for the series and ensure they are in the DB
if (settings.EnableTags && processedTags.Count > 0)
if (settings.EnableTags && !series.Metadata.TagsLocked && processedTags.Count > 0)
{
_logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name);
var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize)))
Expand All @@ -591,22 +596,36 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e

#region Age Rating

// Determine Age Rating
var ageRating = DetermineAgeRating(processedGenres.Concat(processedTags), settings.AgeRatingMappings);
if (!series.Metadata.AgeRatingLocked && series.Metadata.AgeRating <= ageRating)
if (!series.Metadata.AgeRatingLocked)
{
series.Metadata.AgeRating = ageRating;
_unitOfWork.SeriesRepository.Update(series);
madeModification = true;
try
{
// Determine Age Rating
var totalTags = processedGenres
.Concat(processedTags)
.Concat(series.Metadata.Genres.Select(g => g.Title))
.Concat(series.Metadata.Tags.Select(g => g.Title));

var ageRating = DetermineAgeRating(totalTags, settings.AgeRatingMappings);
if (!series.Metadata.AgeRatingLocked && series.Metadata.AgeRating <= ageRating)
{
series.Metadata.AgeRating = ageRating;
_unitOfWork.SeriesRepository.Update(series);
madeModification = true;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue determining Age Rating for Series {SeriesName} ({SeriesId})", series.Name, series.Id);
}
}

#endregion

#region People

if (settings.EnablePeople)
{
series.Metadata.People ??= new List<SeriesMetadataPeople>();
series.Metadata.People ??= [];

// Ensure all people are named correctly
externalMetadata.Staff = externalMetadata.Staff.Select(s =>
Expand Down Expand Up @@ -635,7 +654,10 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e
Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = CleanSummary(w.Description),
}).ToList();
})
.Concat(series.Metadata.People.Where(p => p.Role == PersonRole.Writer).Select(p => _mapper.Map<PersonDto>(p)))
.DistinctBy(p => Parser.Normalize(p.Name))
.ToList();


// NOTE: PersonRoles can be a hashset
Expand All @@ -661,7 +683,10 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e
Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = CleanSummary(w.Description),
}).ToList();
})
.Concat(series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => _mapper.Map<PersonDto>(p)))
.DistinctBy(p => Parser.Normalize(p.Name))
.ToList();

if (!series.Metadata.CoverArtistLocked && artists.Count > 0 && settings.PersonRoles.Contains(PersonRole.CoverArtist))
{
Expand All @@ -684,7 +709,10 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e
Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
Description = CleanSummary(w.Description),
}).ToList();
})
.Concat(series.Metadata.People.Where(p => p.Role == PersonRole.Character).Select(p => _mapper.Map<PersonDto>(p)))
.DistinctBy(p => Parser.Normalize(p.Name))
.ToList();


if (!series.Metadata.CharacterLocked && characters.Count > 0)
Expand Down Expand Up @@ -713,13 +741,27 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e

#endregion

#region Publication Status

if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus)
{
var chapters = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes.SelectMany(v => v.Chapters).ToList();
var wasChanged = DeterminePublicationStatus(series, chapters, externalMetadata);
_unitOfWork.SeriesRepository.Update(series);
madeModification = madeModification || wasChanged;
try
{
var chapters =
(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes
.SelectMany(v => v.Chapters).ToList();
var wasChanged = DeterminePublicationStatus(series, chapters, externalMetadata);
_unitOfWork.SeriesRepository.Update(series);
madeModification = madeModification || wasChanged;
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue determining Publication Status for Series {SeriesName} ({SeriesId})", series.Name, series.Id);
}
}
#endregion

#region Relationships

if (settings.EnableRelationships && externalMetadata.Relations != null && defaultAdmin != null)
{
Expand Down Expand Up @@ -773,6 +815,7 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e
madeModification = true;
}
}
#endregion

return madeModification;
}
Expand Down Expand Up @@ -889,6 +932,8 @@ private bool DeterminePublicationStatus(Series series, List<Chapter> chapters, E
private static AgeRating DetermineAgeRating(IEnumerable<string> values, Dictionary<string, AgeRating> mappings)
{
// Find highest age rating from mappings
mappings ??= new Dictionary<string, AgeRating>();

return values
.Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown)
.DefaultIfEmpty(AgeRating.Unknown)
Expand All @@ -913,6 +958,7 @@ private async Task<ExternalSeriesMetadata> GetOrCreateExternalSeriesMetadataForS
};
series.ExternalSeriesMetadata = externalSeriesMetadata;
_unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata);

return externalSeriesMetadata;
}

Expand Down
3 changes: 2 additions & 1 deletion API/Services/SeriesService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,8 @@ public static async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, IColle
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);

// Use a dictionary for quick lookups
var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName).ToDictionary(p => p.NormalizedName, p => p);
var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName)
.ToDictionary(p => p.NormalizedName, p => p);

// List to track people that will be added to the metadata
var peopleToAdd = new List<Person>();
Expand Down
4 changes: 2 additions & 2 deletions API/Services/Tasks/ScannerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -450,12 +450,12 @@ await _eventHub.SendMessageAsync(MessageFactory.Error,
// That way logging and UI informing is all in one place with full context
_logger.LogError("[ScannerService] Some of the root folders for the library are empty. " +
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
"Scan has be aborted. " +
"Scan has been aborted. " +
"Check that your mount is connected or change the library's root folder and rescan");

await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.",
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
"Scan has be aborted. " +
"Scan has been aborted. " +
"Check that your mount is connected or change the library's root folder and rescan"));

return false;
Expand Down
3 changes: 2 additions & 1 deletion API/Services/Tasks/VersionUpdaterService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ private async Task EnrichWithNightlyInfo(List<UpdateNotificationDto> dtos)

var nightlyDto = new UpdateNotificationDto
{
// TODO: I should pass Title to the FE so that Nightly Release can be localized
UpdateTitle = $"Nightly Release {nightly.Version} - {prInfo.Title}",
UpdateVersion = nightly.Version,
CurrentVersion = dto.CurrentVersion,
Expand Down Expand Up @@ -446,7 +447,7 @@ private static Dictionary<string, List<string>> ParseReleaseBody(string body)
{
var sections = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
var lines = body.Split('\n');
string currentSection = null;
string? currentSection = null;

foreach (var line in lines)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export interface ExternalSeriesDetail {
summary?: string;
volumeCount?: number;
chapterCount?: number;
/**
* These are duplicated with volumeCount based on where it's being invoked.
*/
volumes?: number;
chapters?: number;
staff: Array<SeriesStaff>;
tags: Array<MetadataTagDto>;
provider: ScrobbleProvider;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ <h4 class="header">{{t('tags-title')}}</h4>
<div class="mb-3">
<app-carousel-reel [items]="webLinks" [title]="t('weblinks-title')">
<ng-template #carouselItem let-item>
<a class="me-1" [href]="item | safeHtml" target="_blank" rel="noopener noreferrer" [title]="item">
<a class="me-1" [href]="item | safeUrl" target="_blank" rel="noopener noreferrer" [title]="item">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(item)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {SeriesFormatComponent} from "../../shared/series-format/series-format.co
import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
import {LanguageNamePipe} from "../../_pipes/language-name.pipe";
import {AsyncPipe} from "@angular/common";
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";

@Component({
selector: 'app-details-tab',
Expand All @@ -39,7 +40,8 @@ import {AsyncPipe} from "@angular/common";
SeriesFormatComponent,
MangaFormatPipe,
LanguageNamePipe,
AsyncPipe
AsyncPipe,
SafeUrlPipe
],
templateUrl: './details-tab.component.html',
styleUrl: './details-tab.component.scss',
Expand Down
Loading

0 comments on commit bb9621a

Please sign in to comment.