Metadata Fixes (#3533)

Co-authored-by: Midhun Sudhir <60651970+midhun3301@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-02-06 16:47:29 -06:00 committed by GitHub
parent 40bbdcb5f0
commit bb9621a588
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 151 additions and 56 deletions

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs.KavitaPlus.Metadata;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Enums.UserPreferences; using API.Entities.Enums.UserPreferences;
@ -217,6 +218,12 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<string>>(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 // Configure one-to-many relationship
builder.Entity<MetadataSettings>() builder.Entity<MetadataSettings>()
@ -224,6 +231,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.WithOne(x => x.MetadataSettings) .WithOne(x => x.MetadataSettings)
.HasForeignKey(x => x.MetadataSettingsId) .HasForeignKey(x => x.MetadataSettingsId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.Entity<MetadataSettings>() builder.Entity<MetadataSettings>()
.Property(b => b.Enabled) .Property(b => b.Enabled)
.HasDefaultValue(true); .HasDefaultValue(true);

View File

@ -224,6 +224,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
public async Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter) public async Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter)
{ {
return await _context.Series return await _context.Series
.Include(s => s.Library)
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
.FilterMatchState(filter.MatchStateOption) .FilterMatchState(filter.MatchStateOption)
.OrderBy(s => s.NormalizedName) .OrderBy(s => s.NormalizedName)

View File

@ -308,7 +308,7 @@ public static class Seed
EnableTags = false, EnableTags = false,
EnableGenres = true, EnableGenres = true,
EnableLocalizedName = false, EnableLocalizedName = false,
FirstLastPeopleNaming = false, FirstLastPeopleNaming = true,
PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character] PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]
}; };
await context.MetadataSettings.AddAsync(existing); await context.MetadataSettings.AddAsync(existing);

View File

@ -288,11 +288,15 @@ public static class QueryableExtensions
return stateOption switch return stateOption switch
{ {
MatchStateOption.All => query, MatchStateOption.All => query,
MatchStateOption.Matched => query.Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted), MatchStateOption.Matched => query
MatchStateOption.NotMatched => query.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted), .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.Error => query.Where(s => s.IsBlacklisted),
MatchStateOption.DontMatch => query.Where(s => s.DontMatch), MatchStateOption.DontMatch => query.Where(s => s.DontMatch),
_ => throw new ArgumentOutOfRangeException(nameof(stateOption), stateOption, null) _ => query
}; };
} }
} }

View File

@ -345,10 +345,13 @@ public class AutoMapperProfiles : Profile
opt.MapFrom(src => src)) opt.MapFrom(src => src))
.ForMember(dest => dest.IsMatched, .ForMember(dest => dest.IsMatched,
opt => 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, .ForMember(dest => dest.ValidUntilUtc,
opt => opt => opt.MapFrom(src =>
opt.MapFrom(src => src.ExternalSeriesMetadata.ValidUntilUtc)); src.ExternalSeriesMetadata != null
? src.ExternalSeriesMetadata.ValidUntilUtc
: DateTime.MinValue));
CreateMap<MangaFile, FileExtensionExportDto>(); CreateMap<MangaFile, FileExtensionExportDto>();
@ -361,10 +364,14 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId)) .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)); .ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type));
CreateMap<MetadataFieldMapping, MetadataFieldMappingDto>();
CreateMap<MetadataSettings, MetadataSettingsDto>() CreateMap<MetadataSettings, MetadataSettingsDto>()
.ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List<string>())) .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>())); .ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List<string>()))
CreateMap<MetadataFieldMapping, MetadataFieldMappingDto>(); .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
} }
} }

View File

@ -195,6 +195,7 @@ public class ExternalMetadataService : IExternalMetadataService
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata); SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata);
if (series == null) return [];
var potentialAnilistId = ScrobblingService.ExtractId<int?>(dto.Query, ScrobblingService.AniListWeblinkWebsite); var potentialAnilistId = ScrobblingService.ExtractId<int?>(dto.Query, ScrobblingService.AniListWeblinkWebsite);
var potentialMalId = ScrobblingService.ExtractId<long?>(dto.Query, ScrobblingService.MalWeblinkWebsite); var potentialMalId = ScrobblingService.ExtractId<long?>(dto.Query, ScrobblingService.MalWeblinkWebsite);
@ -512,7 +513,7 @@ public class ExternalMetadataService : IExternalMetadataService
madeModification = true; madeModification = true;
} }
if (settings.EnableStartDate && externalMetadata.StartDate.HasValue) if (settings.EnableStartDate && !series.Metadata.ReleaseYearLocked && externalMetadata.StartDate.HasValue)
{ {
series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year;
madeModification = true; madeModification = true;
@ -526,7 +527,7 @@ public class ExternalMetadataService : IExternalMetadataService
// Process Genres // Process Genres
if (externalMetadata.Genres != null) 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 // Apply field mappings
var mappedGenre = ApplyFieldMapping(genre, MetadataFieldType.Genre, settings.FieldMappings); var mappedGenre = ApplyFieldMapping(genre, MetadataFieldType.Genre, settings.FieldMappings);
@ -537,9 +538,12 @@ public class ExternalMetadataService : IExternalMetadataService
} }
// Strip blacklisted items from processedGenres // 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); _logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name);
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList();
@ -567,13 +571,14 @@ public class ExternalMetadataService : IExternalMetadataService
} }
// Strip blacklisted items from processedTags // Strip blacklisted items from processedTags
processedTags = processedTags.Distinct() processedTags = processedTags
.Distinct()
.Where(g => !settings.Blacklist.Contains(g)) .Where(g => !settings.Blacklist.Contains(g))
.Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g)) .Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g))
.ToList(); .ToList();
// Set the tags for the series and ensure they are in the DB // 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); _logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name);
var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize))) var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize)))
@ -591,22 +596,36 @@ public class ExternalMetadataService : IExternalMetadataService
#region Age Rating #region Age Rating
// Determine Age Rating if (!series.Metadata.AgeRatingLocked)
var ageRating = DetermineAgeRating(processedGenres.Concat(processedTags), settings.AgeRatingMappings);
if (!series.Metadata.AgeRatingLocked && series.Metadata.AgeRating <= ageRating)
{ {
series.Metadata.AgeRating = ageRating; try
_unitOfWork.SeriesRepository.Update(series); {
madeModification = true; // 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 #endregion
#region People #region People
if (settings.EnablePeople) if (settings.EnablePeople)
{ {
series.Metadata.People ??= new List<SeriesMetadataPeople>(); series.Metadata.People ??= [];
// Ensure all people are named correctly // Ensure all people are named correctly
externalMetadata.Staff = externalMetadata.Staff.Select(s => externalMetadata.Staff = externalMetadata.Staff.Select(s =>
@ -635,7 +654,10 @@ public class ExternalMetadataService : IExternalMetadataService
Name = w.Name, Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = CleanSummary(w.Description), 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 // NOTE: PersonRoles can be a hashset
@ -661,7 +683,10 @@ public class ExternalMetadataService : IExternalMetadataService
Name = w.Name, Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = CleanSummary(w.Description), 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)) if (!series.Metadata.CoverArtistLocked && artists.Count > 0 && settings.PersonRoles.Contains(PersonRole.CoverArtist))
{ {
@ -684,7 +709,10 @@ public class ExternalMetadataService : IExternalMetadataService
Name = w.Name, Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
Description = CleanSummary(w.Description), 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) if (!series.Metadata.CharacterLocked && characters.Count > 0)
@ -713,13 +741,27 @@ public class ExternalMetadataService : IExternalMetadataService
#endregion #endregion
#region Publication Status
if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus) if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus)
{ {
var chapters = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes.SelectMany(v => v.Chapters).ToList(); try
var wasChanged = DeterminePublicationStatus(series, chapters, externalMetadata); {
_unitOfWork.SeriesRepository.Update(series); var chapters =
madeModification = madeModification || wasChanged; (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) if (settings.EnableRelationships && externalMetadata.Relations != null && defaultAdmin != null)
{ {
@ -773,6 +815,7 @@ public class ExternalMetadataService : IExternalMetadataService
madeModification = true; madeModification = true;
} }
} }
#endregion
return madeModification; return madeModification;
} }
@ -889,6 +932,8 @@ public class ExternalMetadataService : IExternalMetadataService
private static AgeRating DetermineAgeRating(IEnumerable<string> values, Dictionary<string, AgeRating> mappings) private static AgeRating DetermineAgeRating(IEnumerable<string> values, Dictionary<string, AgeRating> mappings)
{ {
// Find highest age rating from mappings // Find highest age rating from mappings
mappings ??= new Dictionary<string, AgeRating>();
return values return values
.Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown) .Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown)
.DefaultIfEmpty(AgeRating.Unknown) .DefaultIfEmpty(AgeRating.Unknown)
@ -913,6 +958,7 @@ public class ExternalMetadataService : IExternalMetadataService
}; };
series.ExternalSeriesMetadata = externalSeriesMetadata; series.ExternalSeriesMetadata = externalSeriesMetadata;
_unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata); _unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata);
return externalSeriesMetadata; return externalSeriesMetadata;
} }

View File

@ -349,7 +349,8 @@ public class SeriesService : ISeriesService
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
// Use a dictionary for quick lookups // 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 // List to track people that will be added to the metadata
var peopleToAdd = new List<Person>(); var peopleToAdd = new List<Person>();

View File

@ -450,12 +450,12 @@ public class ScannerService : IScannerService
// That way logging and UI informing is all in one place with full context // 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. " + _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. " + "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"); "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.", 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. " + "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")); "Check that your mount is connected or change the library's root folder and rescan"));
return false; return false;

View File

@ -114,6 +114,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
var nightlyDto = new UpdateNotificationDto 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}", UpdateTitle = $"Nightly Release {nightly.Version} - {prInfo.Title}",
UpdateVersion = nightly.Version, UpdateVersion = nightly.Version,
CurrentVersion = dto.CurrentVersion, CurrentVersion = dto.CurrentVersion,
@ -446,7 +447,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
{ {
var sections = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); var sections = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
var lines = body.Split('\n'); var lines = body.Split('\n');
string currentSection = null; string? currentSection = null;
foreach (var line in lines) foreach (var line in lines)
{ {

View File

@ -37,6 +37,11 @@ export interface ExternalSeriesDetail {
summary?: string; summary?: string;
volumeCount?: number; volumeCount?: number;
chapterCount?: number; chapterCount?: number;
/**
* These are duplicated with volumeCount based on where it's being invoked.
*/
volumes?: number;
chapters?: number;
staff: Array<SeriesStaff>; staff: Array<SeriesStaff>;
tags: Array<MetadataTagDto>; tags: Array<MetadataTagDto>;
provider: ScrobbleProvider; provider: ScrobbleProvider;

View File

@ -63,7 +63,7 @@
<div class="mb-3"> <div class="mb-3">
<app-carousel-reel [items]="webLinks" [title]="t('weblinks-title')"> <app-carousel-reel [items]="webLinks" [title]="t('weblinks-title')">
<ng-template #carouselItem let-item> <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)" <app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(item)"
[errorImage]="imageService.errorWebLinkImage"></app-image> [errorImage]="imageService.errorWebLinkImage"></app-image>
</a> </a>

View File

@ -22,6 +22,7 @@ import {SeriesFormatComponent} from "../../shared/series-format/series-format.co
import {MangaFormatPipe} from "../../_pipes/manga-format.pipe"; import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
import {LanguageNamePipe} from "../../_pipes/language-name.pipe"; import {LanguageNamePipe} from "../../_pipes/language-name.pipe";
import {AsyncPipe} from "@angular/common"; import {AsyncPipe} from "@angular/common";
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
@Component({ @Component({
selector: 'app-details-tab', selector: 'app-details-tab',
@ -39,7 +40,8 @@ import {AsyncPipe} from "@angular/common";
SeriesFormatComponent, SeriesFormatComponent,
MangaFormatPipe, MangaFormatPipe,
LanguageNamePipe, LanguageNamePipe,
AsyncPipe AsyncPipe,
SafeUrlPipe
], ],
templateUrl: './details-tab.component.html', templateUrl: './details-tab.component.html',
styleUrl: './details-tab.component.scss', styleUrl: './details-tab.component.scss',

View File

@ -32,9 +32,9 @@
} @else { } @else {
<div class="d-flex p-1 justify-content-between"> <div class="d-flex p-1 justify-content-between">
<span class="me-1"><a (click)="$event.stopPropagation()" [href]="item.series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span> <span class="me-1"><a (click)="$event.stopPropagation()" [href]="item.series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span>
@if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) { @if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span> <span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span> <span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
} @else { } @else {
<span class="me-1">{{t('releasing')}}</span> <span class="me-1">{{t('releasing')}}</span>
} }

View File

@ -4,7 +4,7 @@
<a class="position-absolute custom-position btn btn-primary-outline" [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">{{t('faq-title')}}</a> <a class="position-absolute custom-position btn btn-primary-outline" [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">{{t('faq-title')}}</a>
</div> </div>
<div class="container-fluid"> <div>
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}}</p> <p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}}</p>
<form [formGroup]="formGroup"> <form [formGroup]="formGroup">

View File

@ -31,6 +31,7 @@ export class ManageLogsComponent implements OnInit, OnDestroy {
constructor(private accountService: AccountService) { } constructor(private accountService: AccountService) { }
ngOnInit(): void { ngOnInit(): void {
// TODO: Come back and implement this one day
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) { if (user) {
this.hubConnection = new HubConnectionBuilder() this.hubConnection = new HubConnectionBuilder()

View File

@ -25,7 +25,7 @@ const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:
styleUrls: ['./manage-settings.component.scss'], styleUrls: ['./manage-settings.component.scss'],
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, NgbTooltip, TitleCasePipe, TranslocoModule, NgTemplateOutlet, PageLayoutModePipe, SettingItemComponent, SettingSwitchComponent, SafeHtmlPipe] imports: [ReactiveFormsModule, TitleCasePipe, TranslocoModule, SettingItemComponent, SettingSwitchComponent]
}) })
export class ManageSettingsComponent implements OnInit { export class ManageSettingsComponent implements OnInit {

View File

@ -18,7 +18,6 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {ConfirmService} from "../../shared/confirm.service";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {DefaultModalOptions} from "../../_models/default-modal-options"; import {DefaultModalOptions} from "../../_models/default-modal-options";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
@ -38,7 +37,8 @@ interface AdhocTask {
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe, imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe,
TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, SettingButtonComponent, NgxDatatableModule] TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent,
SettingButtonComponent, NgxDatatableModule]
}) })
export class ManageTasksSettingsComponent implements OnInit { export class ManageTasksSettingsComponent implements OnInit {

View File

@ -2,7 +2,7 @@
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.blog-content { :host ::ng-deep .blog-content {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
line-height: 1.6; line-height: 1.6;
word-wrap: break-word; word-wrap: break-word;

View File

@ -27,4 +27,5 @@ export class ChangelogUpdateItemComponent {
@Input({required:true}) update: UpdateVersionEvent | null = null; @Input({required:true}) update: UpdateVersionEvent | null = null;
@Input() index: number = 0; @Input() index: number = 0;
@Input() showExtras: boolean = true; @Input() showExtras: boolean = true;
} }

View File

@ -103,9 +103,7 @@ const blackList = [Action.Edit, Action.Info, Action.IncognitoRead, Action.Read,
MangaFormatPipe, MangaFormatPipe,
DefaultDatePipe, DefaultDatePipe,
TimeAgoPipe, TimeAgoPipe,
TagBadgeComponent,
PublicationStatusPipe, PublicationStatusPipe,
NgbTooltip,
BytesPipe, BytesPipe,
ImageComponent, ImageComponent,
NgbCollapse, NgbCollapse,
@ -117,7 +115,6 @@ const blackList = [Action.Edit, Action.Info, Action.IncognitoRead, Action.Read,
EditListComponent, EditListComponent,
SettingButtonComponent, SettingButtonComponent,
SettingItemComponent, SettingItemComponent,
ReadTimePipe,
], ],
templateUrl: './edit-series-modal.component.html', templateUrl: './edit-series-modal.component.html',
styleUrls: ['./edit-series-modal.component.scss'], styleUrls: ['./edit-series-modal.component.scss'],
@ -655,6 +652,11 @@ export class EditSeriesModalComponent implements OnInit {
case Action.Download: case Action.Download:
this.downloadService.download('series', this.series); this.downloadService.download('series', this.series);
break; break;
case Action.Match:
this.actionService.matchSeries(this.series, _ => {
this.modal.close({success: true, series: this.series, coverImageUpdate: false, updateExternal: true});
});
break;
} }
} }
} }

View File

@ -7,6 +7,13 @@
<h2 class="title text-break"> <h2 class="title text-break">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables> <app-card-actionables (actionHandler)="performAction($event)" [actions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<span>{{person.name}}</span> <span>{{person.name}}</span>
@if (person.aniListId) {
<a class="ms-1" [href]="anilistUrl | safeUrl" target="_blank" rel="noopener noreferrer">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(anilistUrl)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
}
</h2> </h2>
</ng-container> </ng-container>
</app-side-nav-companion-bar> </app-side-nav-companion-bar>
@ -45,6 +52,7 @@
} }
</div> </div>
} }
</div> </div>
</div> </div>
</div> </div>

View File

@ -41,6 +41,7 @@ import {ThemeService} from "../_services/theme.service";
import {DefaultModalOptions} from "../_models/default-modal-options"; import {DefaultModalOptions} from "../_models/default-modal-options";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {LicenseService} from "../_services/license.service"; import {LicenseService} from "../_services/license.service";
import {SafeUrlPipe} from "../_pipes/safe-url.pipe";
@Component({ @Component({
selector: 'app-person-detail', selector: 'app-person-detail',
@ -49,16 +50,15 @@ import {LicenseService} from "../_services/license.service";
AsyncPipe, AsyncPipe,
ImageComponent, ImageComponent,
SideNavCompanionBarComponent, SideNavCompanionBarComponent,
NgStyle,
ReadMoreComponent, ReadMoreComponent,
TagBadgeComponent, TagBadgeComponent,
PersonRolePipe, PersonRolePipe,
CarouselReelComponent, CarouselReelComponent,
SeriesCardComponent,
CardItemComponent, CardItemComponent,
CardActionablesComponent, CardActionablesComponent,
TranslocoDirective, TranslocoDirective,
ChapterCardComponent ChapterCardComponent,
SafeUrlPipe
], ],
templateUrl: './person-detail.component.html', templateUrl: './person-detail.component.html',
styleUrl: './person-detail.component.scss', styleUrl: './person-detail.component.scss',
@ -93,8 +93,14 @@ export class PersonDetailComponent {
filter: SeriesFilterV2 | null = null; filter: SeriesFilterV2 | null = null;
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this)); personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
chaptersByRole: any = {}; chaptersByRole: any = {};
anilistUrl: string = '';
private readonly personSubject = new BehaviorSubject<Person | null>(null); private readonly personSubject = new BehaviorSubject<Person | null>(null);
protected readonly person$ = this.personSubject.asObservable(); protected readonly person$ = this.personSubject.asObservable().pipe(tap(p => {
if (p?.aniListId) {
this.anilistUrl = translate('person-detail.anilist-url').replace('{AniListId}', p!.aniListId! + '');
this.cdRef.markForCheck();
}
}));
get HasCoverImage() { get HasCoverImage() {
return (this.person as Person).coverImage; return (this.person as Person).coverImage;

View File

@ -1,11 +1,11 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read:'settings'"> <ng-container *transloco="let t; read:'settings'">
<app-side-nav-companion-bar> <app-side-nav-companion-bar>
<h2 title> <h2 class="container-fluid" title>
{{fragment | settingFragment}} {{fragment | settingFragment}}
</h2> </h2>
</app-side-nav-companion-bar> </app-side-nav-companion-bar>
<div class="row col-me-4 pb-3"> <div class="container-fluid row col-me-4 pb-3">
@if (accountService.currentUser$ | async; as user) { @if (accountService.currentUser$ | async; as user) {
@if (accountService.hasAdminRole(user)) { @if (accountService.hasAdminRole(user)) {

View File

@ -763,6 +763,7 @@
"matched-status-label": "Matched", "matched-status-label": "Matched",
"unmatched-status-label": "Not Matched", "unmatched-status-label": "Not Matched",
"blacklist-status-label": "Needs Manual Match", "blacklist-status-label": "Needs Manual Match",
"dont-match-status-label": "{{dont-match-label}}",
"all-status-label": "All", "all-status-label": "All",
"dont-match-label": "Don't Match", "dont-match-label": "Don't Match",
"no-data": "{{common.no-data}}" "no-data": "{{common.no-data}}"
@ -1071,7 +1072,8 @@
"individual-role-title": "As a {{role}}", "individual-role-title": "As a {{role}}",
"browse-person-title": "All Works of {{name}}", "browse-person-title": "All Works of {{name}}",
"browse-person-by-role-title": "All Works of {{name}} as a {{role}}", "browse-person-by-role-title": "All Works of {{name}} as a {{role}}",
"all-roles": "Roles" "all-roles": "Roles",
"anilist-url": "{{edit-person-modal.anilist-tooltip}}"
}, },
"library-settings-modal": { "library-settings-modal": {
@ -2677,7 +2679,7 @@
"title": "Actions", "title": "Actions",
"copy-settings": "Copy Settings From", "copy-settings": "Copy Settings From",
"match": "Match", "match": "Match",
"match-description": "Match Series with Kavita+ manually" "match-tooltip": "Match Series with Kavita+ manually"
}, },
"preferences": { "preferences": {

View File

@ -2,7 +2,7 @@
"openapi": "3.0.1", "openapi": "3.0.1",
"info": { "info": {
"title": "Kavita", "title": "Kavita",
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.9", "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.10",
"license": { "license": {
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"