Stats & More Polish on Metadata Matching (#3538)

This commit is contained in:
Joe Milazzo 2025-02-09 14:39:43 -06:00 committed by GitHub
parent 6f3ba0948b
commit 5d6a5f0987
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 178 additions and 124 deletions

View File

@ -572,8 +572,8 @@ public class SettingsController : BaseApiController
existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? []; existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? [];
existingMetadataSetting.Blacklist = dto.Blacklist.DistinctBy(d => d.ToNormalized()).ToList() ?? []; existingMetadataSetting.Blacklist = dto.Blacklist.Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? [];
existingMetadataSetting.Whitelist = dto.Whitelist.DistinctBy(d => d.ToNormalized()).ToList() ?? []; existingMetadataSetting.Whitelist = dto.Whitelist.Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? [];
existingMetadataSetting.Overrides = dto.Overrides.ToList() ?? []; existingMetadataSetting.Overrides = dto.Overrides.ToList() ?? [];
// Handle Field Mappings // Handle Field Mappings

View File

@ -55,6 +55,11 @@ public class ServerInfoV3Dto
/// </summary> /// </summary>
/// <remarks>This pings a health check and does not capture any IP Information</remarks> /// <remarks>This pings a health check and does not capture any IP Information</remarks>
public long TimeToPingKavitaStatsApi { get; set; } public long TimeToPingKavitaStatsApi { get; set; }
/// <summary>
/// If using the downloading metadata feature
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public bool MatchedMetadataEnabled { get; set; }

View File

@ -36,7 +36,7 @@ public interface IExternalSeriesMetadataRepository
Task<SeriesDetailPlusDto?> GetSeriesDetailPlusDto(int seriesId); Task<SeriesDetailPlusDto?> GetSeriesDetailPlusDto(int seriesId);
Task LinkRecommendationsToSeries(Series series); Task LinkRecommendationsToSeries(Series series);
Task<bool> IsBlacklistedSeries(int seriesId); Task<bool> IsBlacklistedSeries(int seriesId);
Task<IList<int>> GetAllSeriesIdsWithoutMetadata(int limit); Task<IList<int>> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false);
Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter); Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter);
} }
@ -209,11 +209,13 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
} }
public async Task<IList<int>> GetAllSeriesIdsWithoutMetadata(int limit) public async Task<IList<int>> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false)
{ {
return await _context.Series return await _context.Series
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
.Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow) .WhereIf(includeStaleData, s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow)
.Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue)
.Where(s => s.Library.AllowMetadataMatching)
.OrderByDescending(s => s.Library.Type) .OrderByDescending(s => s.Library.Type)
.ThenBy(s => s.NormalizedName) .ThenBy(s => s.NormalizedName)
.Select(s => s.Id) .Select(s => s.Id)

View File

@ -18,4 +18,16 @@ public static class FlurlExtensions
.WithHeader("Content-Type", "application/json") .WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)); .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs));
} }
public static IFlurlRequest WithBasicHeaders(this string request, string apiKey)
{
return request
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", apiKey)
.WithHeader("x-installId", HashUtil.ServerToken())
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs));
}
} }

View File

@ -294,7 +294,7 @@ public static class QueryableExtensions
MatchStateOption.NotMatched => query. MatchStateOption.NotMatched => query.
Include(s => s.ExternalSeriesMetadata) Include(s => s.ExternalSeriesMetadata)
.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted && !s.DontMatch), .Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted && !s.DontMatch),
MatchStateOption.Error => query.Where(s => s.IsBlacklisted), MatchStateOption.Error => query.Where(s => s.IsBlacklisted && !s.DontMatch),
MatchStateOption.DontMatch => query.Where(s => s.DontMatch), MatchStateOption.DontMatch => query.Where(s => s.DontMatch),
_ => query _ => query
}; };

View File

@ -44,8 +44,8 @@ public interface IExternalMetadataService
/// </summary> /// </summary>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <param name="libraryType"></param> /// <param name="libraryType"></param>
/// <returns></returns> /// <returns>If the fetch was made</returns>
Task FetchSeriesMetadata(int seriesId, LibraryType libraryType); Task<bool> FetchSeriesMetadata(int seriesId, LibraryType libraryType);
Task<IList<MalStackDto>> GetStacksForUser(int userId); Task<IList<MalStackDto>> GetStacksForUser(int userId);
Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto); Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto);
@ -73,6 +73,7 @@ public class ExternalMetadataService : IExternalMetadataService
}; };
// Allow 50 requests per 24 hours // Allow 50 requests per 24 hours
private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false);
static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$");
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper, public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper,
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService) ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService)
@ -109,7 +110,7 @@ public class ExternalMetadataService : IExternalMetadataService
public async Task FetchExternalDataTask() public async Task FetchExternalDataTask()
{ {
// Find all Series that are eligible and limit // Find all Series that are eligible and limit
var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeriesIdsWithoutMetadata(25); var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25);
if (ids.Count == 0) return; if (ids.Count == 0) return;
_logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count); _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count);
@ -118,9 +119,9 @@ public class ExternalMetadataService : IExternalMetadataService
foreach (var seriesId in ids) foreach (var seriesId in ids)
{ {
var libraryType = libTypes[seriesId]; var libraryType = libTypes[seriesId];
await FetchSeriesMetadata(seriesId, libraryType); var success = await FetchSeriesMetadata(seriesId, libraryType);
if (success) count++;
await Task.Delay(1500); await Task.Delay(1500);
count++;
} }
_logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count); _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count);
} }
@ -131,10 +132,10 @@ public class ExternalMetadataService : IExternalMetadataService
/// </summary> /// </summary>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <param name="libraryType"></param> /// <param name="libraryType"></param>
public async Task FetchSeriesMetadata(int seriesId, LibraryType libraryType) public async Task<bool> FetchSeriesMetadata(int seriesId, LibraryType libraryType)
{ {
if (!IsPlusEligible(libraryType)) return; if (!IsPlusEligible(libraryType)) return false;
if (!await _licenseService.HasActiveLicense()) return; if (!await _licenseService.HasActiveLicense()) return false;
// Generate key based on seriesId and libraryType or any unique identifier for the request // Generate key based on seriesId and libraryType or any unique identifier for the request
// Check if the request is allowed based on the rate limit // Check if the request is allowed based on the rate limit
@ -142,14 +143,14 @@ public class ExternalMetadataService : IExternalMetadataService
{ {
// Request not allowed due to rate limit // Request not allowed due to rate limit
_logger.LogDebug("Rate Limit hit for Kavita+ prefetch"); _logger.LogDebug("Rate Limit hit for Kavita+ prefetch");
return; return false;
} }
_logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId); _logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId);
// Prefetch SeriesDetail data // Prefetch SeriesDetail data
await GetSeriesDetailPlus(seriesId, libraryType); await GetSeriesDetailPlus(seriesId, libraryType);
return true;
} }
public async Task<IList<MalStackDto>> GetStacksForUser(int userId) public async Task<IList<MalStackDto>> GetStacksForUser(int userId)
@ -512,31 +513,43 @@ public class ExternalMetadataService : IExternalMetadataService
var madeModification = false; var madeModification = false;
if (settings.EnableLocalizedName && (!series.LocalizedNameLocked || settings.HasOverride(MetadataSettingField.LocalizedName))) if (settings.EnableLocalizedName && (settings.HasOverride(MetadataSettingField.LocalizedName)
|| !series.LocalizedNameLocked && !string.IsNullOrWhiteSpace(series.LocalizedName)))
{ {
// We need to make the best appropriate guess // We need to make the best appropriate guess
if (externalMetadata.Name == series.Name) if (externalMetadata.Name == series.Name)
{ {
// Choose closest (usually last) synonym // Choose closest (usually last) synonym
series.LocalizedName = externalMetadata.Synonyms.Last(); var validSynonyms = externalMetadata.Synonyms
.Where(IsRomanCharacters)
.Where(s => s.ToNormalized() != series.Name.ToNormalized())
.ToList();
if (validSynonyms.Count != 0)
{
series.LocalizedName = validSynonyms[^1];
series.LocalizedNameLocked = true;
}
} }
else else if (IsRomanCharacters(externalMetadata.Name))
{ {
series.LocalizedName = externalMetadata.Name; series.LocalizedName = externalMetadata.Name;
series.LocalizedNameLocked = true;
} }
madeModification = true; madeModification = true;
} }
if (settings.EnableSummary && (!series.Metadata.SummaryLocked || if (settings.EnableSummary && (settings.HasOverride(MetadataSettingField.Summary) ||
settings.HasOverride(MetadataSettingField.Summary))) (!series.Metadata.SummaryLocked && !string.IsNullOrWhiteSpace(series.Metadata.Summary))))
{ {
series.Metadata.Summary = CleanSummary(externalMetadata.Summary); series.Metadata.Summary = CleanSummary(externalMetadata.Summary);
madeModification = true; madeModification = true;
} }
if (settings.EnableStartDate && externalMetadata.StartDate.HasValue && (!series.Metadata.ReleaseYearLocked || if (settings.EnableStartDate && externalMetadata.StartDate.HasValue && (settings.HasOverride(MetadataSettingField.StartDate) ||
settings.HasOverride(MetadataSettingField.StartDate))) (!series.Metadata.ReleaseYearLocked &&
series.Metadata.ReleaseYear == 0)))
{ {
series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year;
madeModification = true; madeModification = true;

View File

@ -382,10 +382,12 @@ public class SeriesService : ISeriesService
// Check if the person exists in the dictionary // Check if the person exists in the dictionary
if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p)) if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p))
{ {
// TODO: Should I add more controls here to map back?
if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId) if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId)
{ {
p.AniListId = personDto.AniListId; p.AniListId = personDto.AniListId;
} }
p.Description = string.IsNullOrEmpty(p.Description) ? personDto.Description : p.Description;
continue; // If we ever want to update metadata for existing people, we'd do it here continue; // If we ever want to update metadata for existing people, we'd do it here
} }

View File

@ -13,14 +13,17 @@ using API.DTOs.Stats;
using API.DTOs.Stats.V3; using API.DTOs.Stats.V3;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions;
using API.Services.Plus; using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser; using API.Services.Tasks.Scanner.Parser;
using Flurl.Http; using Flurl.Http;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo; using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers; using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services.Tasks; namespace API.Services.Tasks;
@ -45,12 +48,12 @@ public class StatsService : IStatsService
private readonly UserManager<AppUser> _userManager; private readonly UserManager<AppUser> _userManager;
private readonly IEmailService _emailService; private readonly IEmailService _emailService;
private readonly ICacheService _cacheService; private readonly ICacheService _cacheService;
private const string ApiUrl = "https://stats.kavitareader.com"; private readonly string _apiUrl = "";
private const string ApiKey = "MsnvA2DfQqxSK5jh"; // It's not important this is public, just a way to keep bots from hitting the API willy-nilly private const string ApiKey = "MsnvA2DfQqxSK5jh"; // It's not important this is public, just a way to keep bots from hitting the API willy-nilly
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context, public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context,
ILicenseService licenseService, UserManager<AppUser> userManager, IEmailService emailService, ILicenseService licenseService, UserManager<AppUser> userManager, IEmailService emailService,
ICacheService cacheService) ICacheService cacheService, IHostEnvironment environment)
{ {
_logger = logger; _logger = logger;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
@ -60,7 +63,9 @@ public class StatsService : IStatsService
_emailService = emailService; _emailService = emailService;
_cacheService = cacheService; _cacheService = cacheService;
FlurlConfiguration.ConfigureClientForUrl(ApiUrl); FlurlConfiguration.ConfigureClientForUrl(Configuration.StatsApiUrl);
_apiUrl = environment.IsDevelopment() ? "http://localhost:5001" : Configuration.StatsApiUrl;
} }
/// <summary> /// <summary>
@ -98,13 +103,8 @@ public class StatsService : IStatsService
try try
{ {
var response = await (ApiUrl + "/api/v3/stats") var response = await (_apiUrl + "/api/v3/stats")
.WithHeader("Accept", "application/json") .WithBasicHeaders(ApiKey)
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", ApiKey)
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(30))
.PostJsonAsync(data); .PostJsonAsync(data);
if (response.StatusCode != StatusCodes.Status200OK) if (response.StatusCode != StatusCodes.Status200OK)
@ -151,12 +151,8 @@ public class StatsService : IStatsService
try try
{ {
var response = await (ApiUrl + "/api/v2/stats/opt-out?installId=" + installId) var response = await (_apiUrl + "/api/v2/stats/opt-out?installId=" + installId)
.WithHeader("Accept", "application/json") .WithBasicHeaders(ApiKey)
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", ApiKey)
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(30)) .WithTimeout(TimeSpan.FromSeconds(30))
.PostAsync(); .PostAsync();
@ -180,12 +176,8 @@ public class StatsService : IStatsService
try try
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var response = await (ApiUrl + "/api/health/") var response = await (Configuration.StatsApiUrl + "/api/health/")
.WithHeader("Accept", "application/json") .WithBasicHeaders(ApiKey)
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", ApiKey)
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(30)) .WithTimeout(TimeSpan.FromSeconds(30))
.GetAsync(); .GetAsync();
@ -244,6 +236,7 @@ public class StatsService : IStatsService
private async Task<ServerInfoV3Dto> GetStatV3Payload() private async Task<ServerInfoV3Dto> GetStatV3Payload()
{ {
var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var mediaSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings();
var dto = new ServerInfoV3Dto() var dto = new ServerInfoV3Dto()
{ {
InstallId = serverSettings.InstallId, InstallId = serverSettings.InstallId,
@ -256,6 +249,7 @@ public class StatsService : IStatsService
DotnetVersion = Environment.Version.ToString(), DotnetVersion = Environment.Version.ToString(),
OpdsEnabled = serverSettings.EnableOpds, OpdsEnabled = serverSettings.EnableOpds,
EncodeMediaAs = serverSettings.EncodeMediaAs, EncodeMediaAs = serverSettings.EncodeMediaAs,
MatchedMetadataEnabled = mediaSettings.Enabled
}; };
dto.OsLocale = CultureInfo.CurrentCulture.EnglishName; dto.OsLocale = CultureInfo.CurrentCulture.EnglishName;

View File

@ -17,6 +17,7 @@ public static class Configuration
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static string KavitaPlusApiUrl = "https://plus.kavitareader.com"; public static string KavitaPlusApiUrl = "https://plus.kavitareader.com";
public static string StatsApiUrl = "https://stats.kavitareader.com";
public static int Port public static int Port
{ {

View File

@ -32,6 +32,7 @@ your reading collection with your friends and family!
- Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc - Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc
- Ability to customize your dashboard and side nav with smart filters, custom order and visibility toggles - Ability to customize your dashboard and side nav with smart filters, custom order and visibility toggles
- Full Localization Support - Full Localization Support
- Ability to download metadata (available via [Kavita+](https://wiki.kavitareader.com/kavita+))
## Support ## Support

View File

@ -70,8 +70,10 @@ export class MatchSeriesModalComponent implements OnInit {
const model: any = this.formGroup.value; const model: any = this.formGroup.value;
model.seriesId = this.series.id; model.seriesId = this.series.id;
const dontMatchChanged = this.series.dontMatch !== model.dontMatch;
// We need to update the dontMatch status // We need to update the dontMatch status
if (model.dontMatch) { if (dontMatchChanged) {
this.seriesService.updateDontMatch(this.series.id, model.dontMatch).subscribe(_ => { this.seriesService.updateDontMatch(this.series.id, model.dontMatch).subscribe(_ => {
this.modalService.close(true); this.modalService.close(true);
}); });

View File

@ -37,7 +37,7 @@
[limit]="pageInfo.size" [limit]="pageInfo.size"
> >
<ngx-datatable-column name="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('last-modified-header')}} {{t('last-modified-header')}}
</ng-template> </ng-template>
@ -46,7 +46,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="scrobbleEventType" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="scrobbleEventType" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('type-header')}} {{t('type-header')}}
</ng-template> </ng-template>
@ -55,7 +55,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="seriesName" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="seriesName" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('series-header')}} {{t('series-header')}}
</ng-template> </ng-template>
@ -64,7 +64,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="data" [sortable]="false" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="data" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('data-header')}} {{t('data-header')}}
</ng-template> </ng-template>
@ -98,7 +98,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="isPorcessed" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="isPorcessed" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('is-processed-header')}} {{t('is-processed-header')}}
</ng-template> </ng-template>

View File

@ -50,8 +50,12 @@
} }
</div> </div>
} }
</div> </div>
} }
@if (isEmailInvalid$ | async) {
<div class="invalid-feedback" style="display: block"><div>{{t('invalid-email-warning')}}</div></div>
}
</div> </div>
} }
</div> </div>

View File

@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction'; import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
@ -9,23 +9,28 @@ import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component'; import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
import {LibrarySelectorComponent} from '../library-selector/library-selector.component'; import {LibrarySelectorComponent} from '../library-selector/library-selector.component';
import {RoleSelectorComponent} from '../role-selector/role-selector.component'; import {RoleSelectorComponent} from '../role-selector/role-selector.component';
import {NgIf} from '@angular/common'; import {AsyncPipe, NgIf} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {debounceTime, distinctUntilChanged, Observable, startWith, switchMap, tap} from "rxjs";
import {map} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/; const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@Component({ @Component({
selector: 'app-edit-user', selector: 'app-edit-user',
templateUrl: './edit-user.component.html', templateUrl: './edit-user.component.html',
styleUrls: ['./edit-user.component.scss'], styleUrls: ['./edit-user.component.scss'],
standalone: true, standalone: true,
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective], imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class EditUserComponent implements OnInit { export class EditUserComponent implements OnInit {
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly modal = inject(NgbActiveModal); protected readonly modal = inject(NgbActiveModal);
@Input({required: true}) member!: Member; @Input({required: true}) member!: Member;
@ -36,6 +41,7 @@ export class EditUserComponent implements OnInit {
isSaving: boolean = false; isSaving: boolean = false;
userForm: FormGroup = new FormGroup({}); userForm: FormGroup = new FormGroup({});
isEmailInvalid$!: Observable<boolean>;
allowedCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+/'; allowedCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+/';
@ -47,9 +53,17 @@ export class EditUserComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email])); this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required]));
this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)])); this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
this.isEmailInvalid$ = this.userForm.get('email')!.valueChanges.pipe(
startWith(this.member.email),
distinctUntilChanged(),
debounceTime(10),
map(value => !EmailRegex.test(value)),
takeUntilDestroyed(this.destroyRef)
);
this.selectedRestriction = this.member.ageRestriction; this.selectedRestriction = this.member.ageRestriction;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }

View File

@ -9,7 +9,7 @@
[footerHeight]="50" [footerHeight]="50"
> >
<ngx-datatable-column name="emailTemplate" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="emailTemplate" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('template-header')}} {{t('template-header')}}
</ng-template> </ng-template>
@ -19,7 +19,7 @@
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="sendDate" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="sendDate" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('date-header')}} {{t('date-header')}}
</ng-template> </ng-template>
@ -28,7 +28,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="toUserName" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="toUserName" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('user-header')}} {{t('user-header')}}
</ng-template> </ng-template>
@ -37,7 +37,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="sent" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="sent" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('sent-header')}} {{t('sent-header')}}
</ng-template> </ng-template>

View File

@ -24,7 +24,7 @@
[footerHeight]="50" [footerHeight]="50"
> >
<ngx-datatable-column name="series.name" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3"> <ngx-datatable-column prop="series.name" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('series-name-header')}} {{t('series-name-header')}}
</ng-template> </ng-template>
@ -34,7 +34,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="series.libraryId" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3"> <ngx-datatable-column prop="series.libraryId" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="2">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('library-name-header')}} {{t('library-name-header')}}
</ng-template> </ng-template>
@ -43,8 +43,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column prop="status" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ngx-datatable-column name="status" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('status-header')}} {{t('status-header')}}
</ng-template> </ng-template>
@ -64,20 +63,22 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="validUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1"> @if (filterGroup.get('matchState')?.value === MatchStateOption.Matched) {
<ng-template let-column="column" ngx-datatable-header-template> <ngx-datatable-column prop="validUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
{{t('valid-until-header')}} <ng-template let-column="column" ngx-datatable-header-template>
</ng-template> {{t('valid-until-header')}}
<ng-template let-item="row" ngx-datatable-cell-template> </ng-template>
@if (item.series.isBlacklisted || item.series.dontMatch || !item.isMatched) { <ng-template let-item="row" ngx-datatable-cell-template>
{{null | defaultValue}} @if (item.series.isBlacklisted || item.series.dontMatch || !item.isMatched) {
} @else { {{null | defaultValue}}
{{item.validUntilUtc | utcToLocalTime}} } @else {
} {{item.validUntilUtc | utcToLocalTime}}
</ng-template> }
</ngx-datatable-column> </ng-template>
</ngx-datatable-column>
}
<ngx-datatable-column name="" [width]="20" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="" [width]="20" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('actions-header')}} {{t('actions-header')}}
</ng-template> </ng-template>

View File

@ -45,6 +45,7 @@ import {ScanSeriesEvent} from "../../_models/events/scan-series-event";
}) })
export class ManageMatchedMetadataComponent implements OnInit { export class ManageMatchedMetadataComponent implements OnInit {
protected readonly ColumnMode = ColumnMode; protected readonly ColumnMode = ColumnMode;
protected readonly MatchStateOption = MatchStateOption;
protected readonly allMatchStates = allMatchStates.filter(m => m !== MatchStateOption.Matched); // Matched will have too many protected readonly allMatchStates = allMatchStates.filter(m => m !== MatchStateOption.Matched); // Matched will have too many
private readonly licenseService = inject(LicenseService); private readonly licenseService = inject(LicenseService);
@ -119,7 +120,8 @@ export class ManageMatchedMetadataComponent implements OnInit {
fixMatch(series: Series) { fixMatch(series: Series) {
this.actionService.matchSeries(series, result => { this.actionService.matchSeries(series, result => {
if (!result) return; if (!result) return;
this.loadData().subscribe(); this.data = [...this.data.filter(s => s.series.id !== series.id)];
this.cdRef.markForCheck();
}); });
} }
} }

View File

@ -21,7 +21,7 @@
[limit]="15" [limit]="15"
> >
<ngx-datatable-column name="filePath" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3"> <ngx-datatable-column prop="filePath" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('file-header')}} {{t('file-header')}}
</ng-template> </ng-template>
@ -31,7 +31,7 @@
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="comment" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="comment" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('comment-header')}} {{t('comment-header')}}
</ng-template> </ng-template>
@ -40,7 +40,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}} {{t('created-header')}}
</ng-template> </ng-template>

View File

@ -16,8 +16,7 @@ import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-settings"; import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-settings";
import {PersonRole} from "../../_models/metadata/person"; import {PersonRole} from "../../_models/metadata/person";
import {PersonRolePipe} from "../../_pipes/person-role.pipe"; import {PersonRolePipe} from "../../_pipes/person-role.pipe";
import {NgClass} from "@angular/common"; import {allMetadataSettingField, MetadataSettingField} from "../_models/metadata-setting-field";
import {allMetadataSettingField} from "../_models/metadata-setting-field";
import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe"; import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe";
@ -94,7 +93,7 @@ export class ManageMetadataSettingsComponent implements OnInit {
this.settingsForm.addControl('overrides', this.fb.group( this.settingsForm.addControl('overrides', this.fb.group(
Object.fromEntries( Object.fromEntries(
this.allMetadataSettingFields.map((role, index) => [ this.allMetadataSettingFields.map((role: MetadataSettingField, index: number) => [
`override_${index}`, `override_${index}`,
this.fb.control((settings.overrides || []).includes(role)), this.fb.control((settings.overrides || []).includes(role)),
]) ])
@ -182,8 +181,8 @@ export class ManageMetadataSettingsComponent implements OnInit {
...model, ...model,
ageRatingMappings, ageRatingMappings,
fieldMappings: withFieldMappings ? fieldMappings : [], fieldMappings: withFieldMappings ? fieldMappings : [],
blacklist: (model.blacklist || '').split(',').map((item: string) => item.trim()), blacklist: (model.blacklist || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0),
whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()), whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0),
personRoles: Object.entries(this.settingsForm.get('personRoles')!.value) personRoles: Object.entries(this.settingsForm.get('personRoles')!.value)
.filter(([_, value]) => value) .filter(([_, value]) => value)
.map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]), .map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]),

View File

@ -24,7 +24,7 @@
[limit]="15" [limit]="15"
> >
<ngx-datatable-column name="seriesId" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3"> <ngx-datatable-column prop="seriesId" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('series-header')}} {{t('series-header')}}
</ng-template> </ng-template>
@ -34,7 +34,7 @@
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="created" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}} {{t('created-header')}}
</ng-template> </ng-template>
@ -43,7 +43,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="comment" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="comment" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('comment-header')}} {{t('comment-header')}}
</ng-template> </ng-template>

View File

@ -160,7 +160,7 @@
[limit]="15" [limit]="15"
> >
<ngx-datatable-column name="title" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3"> <ngx-datatable-column prop="title" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('job-title-header')}} {{t('job-title-header')}}
</ng-template> </ng-template>
@ -170,7 +170,7 @@
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="lastExecutionUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="lastExecutionUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('last-executed-header')}} {{t('last-executed-header')}}
</ng-template> </ng-template>
@ -179,7 +179,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="cron" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="cron" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('cron-header')}} {{t('cron-header')}}
</ng-template> </ng-template>

View File

@ -11,7 +11,7 @@
[footerHeight]="50" [footerHeight]="50"
> >
<ngx-datatable-column name="username" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="username" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('username-header')}} {{t('username-header')}}
</ng-template> </ng-template>
@ -21,7 +21,7 @@
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="aniListValidUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="aniListValidUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('anilist-header')}} {{t('anilist-header')}}
</ng-template> </ng-template>
@ -34,7 +34,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="validUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="validUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('mal-header')}} {{t('mal-header')}}
</ng-template> </ng-template>

View File

@ -122,7 +122,7 @@
[allowToggle]="false" [allowToggle]="false"
(toggle)="switchTabsToDetail()"> (toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last"> <ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a routerLink="/person/{{item.name}}/" class="dark-exempt btn-icon">{{item.name}}</a> <a routerLink="/person/{{encodeURIComponent(item.name)}}/" class="dark-exempt btn-icon">{{item.name}}</a>
</ng-template> </ng-template>
</app-badge-expander> </app-badge-expander>
</div> </div>

View File

@ -1173,4 +1173,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} }
}, 10); }, 10);
} }
protected readonly encodeURIComponent = encodeURIComponent;
} }

View File

@ -1,11 +1,11 @@
@if (person !== undefined) { @if (person !== undefined) {
<a class="btn btn-icon p-0" routerLink="/person/{{person.name}}"> <a class="btn btn-icon p-0" routerLink="/person/{{encodeURIComponent(person.name)}}">
<div class="tagbadge cursor clickable"> <div class="tagbadge cursor clickable">
<div class="d-flex flex-column align-items-center justify-content-center"> <div class="d-flex flex-column align-items-center justify-content-center">
<div class="image-container d-flex align-items-center justify-content-center"> <div class="image-container d-flex align-items-center justify-content-center">
@if (HasCoverImage) { @if (HasCoverImage) {
<app-image <app-image
objectFit="cover" objectFit="cover"
height="96px" height="96px"
width="96px" width="96px"
[imageUrl]="ImageUrl" [imageUrl]="ImageUrl"

View File

@ -39,4 +39,6 @@ export class PersonBadgeComponent implements OnInit {
this.staff = this.person as SeriesStaff; this.staff = this.person as SeriesStaff;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
protected readonly encodeURIComponent = encodeURIComponent;
} }

View File

@ -10,8 +10,8 @@
div { div {
word-break: break-word; word-break: break-word;
max-width: 75ch; max-width: 120ch;
@media (max-width: $grid-breakpoints-sm) { @media (max-width: $grid-breakpoints-sm) {
max-width: 50ch; max-width: 50ch;
} }

View File

@ -214,6 +214,8 @@ export class LibrarySettingsModalComponent implements OnInit {
} }
this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible); this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsKavitaPlusEligible);
if (!this.IsKavitaPlusEligible) { if (!this.IsKavitaPlusEligible) {
this.libraryForm.get('allowScrobbling')?.disable(); this.libraryForm.get('allowScrobbling')?.disable();
this.libraryForm.get('allowMetadataMatching')?.disable(); this.libraryForm.get('allowMetadataMatching')?.disable();
@ -238,10 +240,12 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get('manageCollections')?.setValue(this.library.manageCollections); this.libraryForm.get('manageCollections')?.setValue(this.library.manageCollections);
this.libraryForm.get('manageReadingLists')?.setValue(this.library.manageReadingLists); this.libraryForm.get('manageReadingLists')?.setValue(this.library.manageReadingLists);
this.libraryForm.get('collapseSeriesRelationships')?.setValue(this.library.collapseSeriesRelationships); this.libraryForm.get('collapseSeriesRelationships')?.setValue(this.library.collapseSeriesRelationships);
this.libraryForm.get('allowScrobbling')?.setValue(this.library.allowScrobbling); this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible ? this.library.allowScrobbling : false);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching); this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsKavitaPlusEligible ? this.library.allowMetadataMatching : false);
this.selectedFolders = this.library.folders; this.selectedFolders = this.library.folders;
this.madeChanges = false; this.madeChanges = false;
for(let fileTypeGroup of allFileTypeGroup) { for(let fileTypeGroup of allFileTypeGroup) {
this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), [])); this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), []));
} }

View File

@ -230,11 +230,13 @@ export class PreferenceNavComponent implements AfterViewInit {
if (res) { if (res) {
const kavitaPlusSection = this.sections[4]; const kavitaPlusSection = this.sections[4];
if (kavitaPlusSection.children.length === 1) { if (kavitaPlusSection.children.length === 1) {
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ManageUserTokens, [Role.Admin]));
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.Metadata, [Role.Admin]));
// Keep all setting type of screens above this line
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.MatchedMetadata, [Role.Admin], kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.MatchedMetadata, [Role.Admin],
this.matchedMetadataBadgeCount$ this.matchedMetadataBadgeCount$
)); ));
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ManageUserTokens, [Role.Admin]));
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.Metadata, [Role.Admin]));
// Scrobbling History needs to be per-user and allow admin to view all // Scrobbling History needs to be per-user and allow admin to view all
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ScrobblingHolds, [])); kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ScrobblingHolds, []));

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read:'manage-devices'"> <ng-container *transloco="let t; read:'manage-devices'">
<div class="position-relative"> <div class="position-relative">
<button class="btn btn-primary-outline position-absolute custom-position" [disabled]="isReadOnly$" (click)="addDevice()"> <button class="btn btn-primary-outline position-absolute custom-position" [disabled]="isReadOnly$ | async" (click)="addDevice()">
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span> <i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span>
</button> </button>
</div> </div>
@ -17,7 +17,7 @@
[limit]="15" [limit]="15"
> >
<ngx-datatable-column name="name" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3"> <ngx-datatable-column prop="name" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('name-label')}} {{t('name-label')}}
</ng-template> </ng-template>
@ -27,7 +27,7 @@
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="email" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="emailAddress" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('email-label')}} {{t('email-label')}}
</ng-template> </ng-template>
@ -36,7 +36,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="platform" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="platform" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('platform-label')}} {{t('platform-label')}}
</ng-template> </ng-template>

View File

@ -8,16 +8,10 @@ import {
import { Device } from 'src/app/_models/device/device'; import { Device } from 'src/app/_models/device/device';
import { DeviceService } from 'src/app/_services/device.service'; import { DeviceService } from 'src/app/_services/device.service';
import { DevicePlatformPipe } from '../../_pipes/device-platform.pipe'; import { DevicePlatformPipe } from '../../_pipes/device-platform.pipe';
import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {NgbCollapse, NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {SettingsService} from "../../admin/settings.service"; import {SettingsService} from "../../admin/settings.service";
import {ConfirmService} from "../../shared/confirm.service"; import {ConfirmService} from "../../shared/confirm.service";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {ScrobbleEventTypePipe} from "../../_pipes/scrobble-event-type.pipe";
import {SortableHeader} from "../../_single-module/table/_directives/sortable-header.directive";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {EditDeviceModalComponent} from "../_modals/edit-device-modal/edit-device-modal.component"; import {EditDeviceModalComponent} from "../_modals/edit-device-modal/edit-device-modal.component";
import {DefaultModalOptions} from "../../_models/default-modal-options"; import {DefaultModalOptions} from "../../_models/default-modal-options";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -25,7 +19,7 @@ import {map} from "rxjs";
import {shareReplay} from "rxjs/operators"; import {shareReplay} from "rxjs/operators";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {AsyncPipe, TitleCasePipe} from "@angular/common"; import {AsyncPipe} from "@angular/common";
@Component({ @Component({
selector: 'app-manage-devices', selector: 'app-manage-devices',
@ -33,8 +27,7 @@ import {AsyncPipe, TitleCasePipe} from "@angular/common";
styleUrls: ['./manage-devices.component.scss'], styleUrls: ['./manage-devices.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [NgbCollapse, SentenceCasePipe, DevicePlatformPipe, TranslocoDirective, SettingItemComponent, imports: [DevicePlatformPipe, TranslocoDirective, AsyncPipe, NgxDatatableModule]
DefaultValuePipe, ScrobbleEventTypePipe, SortableHeader, UtcToLocalTimePipe, AsyncPipe, NgxDatatableModule, TitleCasePipe]
}) })
export class ManageDevicesComponent implements OnInit { export class ManageDevicesComponent implements OnInit {

View File

@ -11,7 +11,7 @@
[footerHeight]="50" [footerHeight]="50"
> >
<ngx-datatable-column name="seriesName" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="seriesName" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('series-name-header')}} {{t('series-name-header')}}
</ng-template> </ng-template>
@ -22,7 +22,7 @@
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="createdUtc" [sortable]="false" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}} {{t('created-header')}}
</ng-template> </ng-template>
@ -31,7 +31,7 @@
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column name="validUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="validUntilUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
</ng-template> </ng-template>

View File

@ -24,7 +24,7 @@
"username": "{{common.username}}", "username": "{{common.username}}",
"required": "{{validation.required-field}}", "required": "{{validation.required-field}}",
"email": "{{common.email}}", "email": "{{common.email}}",
"not-valid-email": "{{validation.valid-email}}", "invalid-email-warning": "A non-valid email will block some functionalities of Kavita",
"cancel": "{{common.cancel}}", "cancel": "{{common.cancel}}",
"saving": "Saving…", "saving": "Saving…",
"update": "Update", "update": "Update",
@ -821,8 +821,7 @@
"first-last-name-tooltip": "Ensure People's names are written First then Last", "first-last-name-tooltip": "Ensure People's names are written First then Last",
"person-roles-label": "Roles", "person-roles-label": "Roles",
"overrides-label": "Overrides", "overrides-label": "Overrides",
"overrides-description": "Allow Kavita to write over locked fields" "overrides-description": "Allow Kavita to write over locked fields."
}, },
"book-line-overlay": { "book-line-overlay": {

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.11", "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.12",
"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"