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.Threading;
using System.Threading.Tasks;
using API.DTOs.KavitaPlus.Metadata;
using API.Entities;
using API.Entities.Enums;
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.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>()
@ -224,6 +231,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.WithOne(x => x.MetadataSettings)
.HasForeignKey(x => x.MetadataSettingsId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<MetadataSettings>()
.Property(b => b.Enabled)
.HasDefaultValue(true);

View File

@ -224,6 +224,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
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)

View File

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

View File

@ -288,11 +288,15 @@ public static class QueryableExtensions
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
};
}
}

View File

@ -345,10 +345,13 @@ public class AutoMapperProfiles : Profile
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>();
@ -361,10 +364,14 @@ public class AutoMapperProfiles : Profile
.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>()));
}
}

View File

@ -195,6 +195,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);
if (series == null) return [];
var potentialAnilistId = ScrobblingService.ExtractId<int?>(dto.Query, ScrobblingService.AniListWeblinkWebsite);
var potentialMalId = ScrobblingService.ExtractId<long?>(dto.Query, ScrobblingService.MalWeblinkWebsite);
@ -512,7 +513,7 @@ public class ExternalMetadataService : IExternalMetadataService
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;
@ -526,7 +527,7 @@ public class ExternalMetadataService : IExternalMetadataService
// 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);
@ -537,9 +538,12 @@ public class ExternalMetadataService : IExternalMetadataService
}
// 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();
@ -567,13 +571,14 @@ public class ExternalMetadataService : IExternalMetadataService
}
// 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)))
@ -591,22 +596,36 @@ public class ExternalMetadataService : IExternalMetadataService
#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 =>
@ -635,7 +654,10 @@ public class ExternalMetadataService : IExternalMetadataService
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
@ -661,7 +683,10 @@ public class ExternalMetadataService : IExternalMetadataService
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))
{
@ -684,7 +709,10 @@ public class ExternalMetadataService : IExternalMetadataService
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)
@ -713,13 +741,27 @@ public class ExternalMetadataService : IExternalMetadataService
#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)
{
@ -773,6 +815,7 @@ public class ExternalMetadataService : IExternalMetadataService
madeModification = true;
}
}
#endregion
return madeModification;
}
@ -889,6 +932,8 @@ public class ExternalMetadataService : IExternalMetadataService
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)
@ -913,6 +958,7 @@ public class ExternalMetadataService : IExternalMetadataService
};
series.ExternalSeriesMetadata = externalSeriesMetadata;
_unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata);
return externalSeriesMetadata;
}

View File

@ -349,7 +349,8 @@ public class SeriesService : ISeriesService
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>();

View File

@ -450,12 +450,12 @@ public class ScannerService : IScannerService
// 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;

View File

@ -114,6 +114,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
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,
@ -446,7 +447,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
{
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)
{

View File

@ -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;

View File

@ -63,7 +63,7 @@
<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>

View File

@ -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',
@ -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',

View File

@ -32,9 +32,9 @@
} @else {
<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>
@if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span>
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
} @else {
<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>
</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>
<form [formGroup]="formGroup">

View File

@ -31,6 +31,7 @@ export class ManageLogsComponent implements OnInit, OnDestroy {
constructor(private accountService: AccountService) { }
ngOnInit(): void {
// TODO: Come back and implement this one day
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
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'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, NgbTooltip, TitleCasePipe, TranslocoModule, NgTemplateOutlet, PageLayoutModePipe, SettingItemComponent, SettingSwitchComponent, SafeHtmlPipe]
imports: [ReactiveFormsModule, TitleCasePipe, TranslocoModule, SettingItemComponent, SettingSwitchComponent]
})
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 {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 {DefaultModalOptions} from "../../_models/default-modal-options";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
@ -38,7 +37,8 @@ interface AdhocTask {
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe,
TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, SettingButtonComponent, NgxDatatableModule]
TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent,
SettingButtonComponent, NgxDatatableModule]
})
export class ManageTasksSettingsComponent implements OnInit {

View File

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

View File

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

View File

@ -103,9 +103,7 @@ const blackList = [Action.Edit, Action.Info, Action.IncognitoRead, Action.Read,
MangaFormatPipe,
DefaultDatePipe,
TimeAgoPipe,
TagBadgeComponent,
PublicationStatusPipe,
NgbTooltip,
BytesPipe,
ImageComponent,
NgbCollapse,
@ -117,7 +115,6 @@ const blackList = [Action.Edit, Action.Info, Action.IncognitoRead, Action.Read,
EditListComponent,
SettingButtonComponent,
SettingItemComponent,
ReadTimePipe,
],
templateUrl: './edit-series-modal.component.html',
styleUrls: ['./edit-series-modal.component.scss'],
@ -655,6 +652,11 @@ export class EditSeriesModalComponent implements OnInit {
case Action.Download:
this.downloadService.download('series', this.series);
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">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<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>
</ng-container>
</app-side-nav-companion-bar>
@ -45,6 +52,7 @@
}
</div>
}
</div>
</div>
</div>

View File

@ -41,6 +41,7 @@ import {ThemeService} from "../_services/theme.service";
import {DefaultModalOptions} from "../_models/default-modal-options";
import {ToastrService} from "ngx-toastr";
import {LicenseService} from "../_services/license.service";
import {SafeUrlPipe} from "../_pipes/safe-url.pipe";
@Component({
selector: 'app-person-detail',
@ -49,16 +50,15 @@ import {LicenseService} from "../_services/license.service";
AsyncPipe,
ImageComponent,
SideNavCompanionBarComponent,
NgStyle,
ReadMoreComponent,
TagBadgeComponent,
PersonRolePipe,
CarouselReelComponent,
SeriesCardComponent,
CardItemComponent,
CardActionablesComponent,
TranslocoDirective,
ChapterCardComponent
ChapterCardComponent,
SafeUrlPipe
],
templateUrl: './person-detail.component.html',
styleUrl: './person-detail.component.scss',
@ -93,8 +93,14 @@ export class PersonDetailComponent {
filter: SeriesFilterV2 | null = null;
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
chaptersByRole: any = {};
anilistUrl: string = '';
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() {
return (this.person as Person).coverImage;

View File

@ -1,11 +1,11 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; read:'settings'">
<app-side-nav-companion-bar>
<h2 title>
<h2 class="container-fluid" title>
{{fragment | settingFragment}}
</h2>
</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.hasAdminRole(user)) {

View File

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

View File

@ -2,7 +2,7 @@
"openapi": "3.0.1",
"info": {
"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": {
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"