A few bug fixes (#3980)

This commit is contained in:
Fesaa 2025-08-05 15:38:46 +02:00 committed by GitHub
parent b0059128b3
commit 881727bd21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 245 additions and 110 deletions

19
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,19 @@
## Coding Guidelines
- Anytime you don't use {} on an if statement, it must be on one line and MUST be a jump operation (return/continue/break). All other times, you must use {} and be on 2 lines.
- Use var whenever possible
- return statements should generally have a newline above them
Examples:
```csharp
# Case when okay - simple logic flow
var a = 2 + 3;
return a;
# Case when needs newline - complex logic is grouped together
var a = b + c;
_imageService.Resize(...);
return;
```
- Operation (+,-,*, etc) should always have spaces around it; I.e. `a + b` not `a+b`.
- Comma's `,` should always be followed by a space
- When setting href directectly (not using Angulars routing) it should always be prefixed with baseURL

View File

@ -28,7 +28,9 @@ public class OidcServiceTests: AbstractDbTest
await ResetDb(); await ResetDb();
var (oidcService, _, _, userManager) = await Setup(); var (oidcService, _, _, userManager) = await Setup();
var user = new AppUserBuilder("holo", "holo@localhost").Build(); var user = new AppUserBuilder("holo", "holo@localhost")
.WithIdentityProvider(IdentityProvider.OpenIdConnect)
.Build();
var res = await userManager.CreateAsync(user); var res = await userManager.CreateAsync(user);
Assert.Empty(res.Errors); Assert.Empty(res.Errors);
Assert.True(res.Succeeded); Assert.True(res.Succeeded);
@ -426,7 +428,9 @@ public class OidcServiceTests: AbstractDbTest
Assert.NotNull(userFromDb); Assert.NotNull(userFromDb);
Assert.NotEqual(AgeRating.Teen, userFromDb.AgeRestriction); Assert.NotEqual(AgeRating.Teen, userFromDb.AgeRestriction);
var newUser = new AppUserBuilder("NotAnAdmin", "NotAnAdmin@localhost").Build(); var newUser = new AppUserBuilder("NotAnAdmin", "NotAnAdmin@localhost")
.WithIdentityProvider(IdentityProvider.OpenIdConnect)
.Build();
var res = await userManager.CreateAsync(newUser); var res = await userManager.CreateAsync(newUser);
Assert.Empty(res.Errors); Assert.Empty(res.Errors);
Assert.True(res.Succeeded); Assert.True(res.Succeeded);
@ -515,7 +519,9 @@ public class OidcServiceTests: AbstractDbTest
var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost") var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost")
.WithRole(PolicyConstants.AdminRole) .WithRole(PolicyConstants.AdminRole)
.Build(); .Build();
var user = new AppUserBuilder("amelia", "amelia@localhost").Build(); var user = new AppUserBuilder("amelia", "amelia@localhost")
.WithIdentityProvider(IdentityProvider.OpenIdConnect)
.Build();
var roleStore = new RoleStore< var roleStore = new RoleStore<
AppRole, AppRole,

View File

@ -80,6 +80,7 @@
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" /> <PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="3.1.0" /> <PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native" Version="8.17.1" /> <PackageReference Include="NetVips.Native" Version="8.17.1" />
<PackageReference Include="Polly" Version="8.6.2" />
<PackageReference Include="Serilog" Version="4.3.0" /> <PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" /> <PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />

View File

@ -2,6 +2,7 @@
using System.Linq; using System.Linq;
using API.Data; using API.Data;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using Kavita.Common; using Kavita.Common;
namespace API.Helpers.Builders; namespace API.Helpers.Builders;
@ -68,4 +69,10 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
_appUser.UserRoles.Add(new AppUserRole() {Role = new AppRole() {Name = role}}); _appUser.UserRoles.Add(new AppUserRole() {Role = new AppRole() {Name = role}});
return this; return this;
} }
public AppUserBuilder WithIdentityProvider(IdentityProvider identityProvider)
{
_appUser.IdentityProvider = identityProvider;
return this;
}
} }

View File

@ -68,10 +68,30 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
/// The name of the Auth Cookie set by .NET /// The name of the Auth Cookie set by .NET
public const string CookieName = ".AspNetCore.Cookies"; public const string CookieName = ".AspNetCore.Cookies";
private OpenIdConnectConfiguration? _discoveryDocument; /// <summary>
/// The ConfigurationManager will refresh the configuration periodically to ensure the data stays up to date
/// We can store the same one indefinitely as the authority does not change unless Kavita is restarted
/// </summary>
/// <remarks>The ConfigurationManager has its own lock, it loads data thread safe</remarks>
private static readonly ConfigurationManager<OpenIdConnectConfiguration> OidcConfigurationManager;
private static readonly ConcurrentDictionary<string, bool> RefreshInProgress = new(); private static readonly ConcurrentDictionary<string, bool> RefreshInProgress = new();
private static readonly ConcurrentDictionary<string, DateTimeOffset> LastFailedRefresh = new(); private static readonly ConcurrentDictionary<string, DateTimeOffset> LastFailedRefresh = new();
#pragma warning disable S3963
static OidcService()
{
var authority = Configuration.OidcSettings.Authority;
var hasTrailingSlash = authority.EndsWith('/');
var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration";
OidcConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
url,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever { RequireHttps = url.StartsWith("https") }
);
}
#pragma warning restore S3963
public async Task<AppUser?> LoginOrCreate(HttpRequest request, ClaimsPrincipal principal) public async Task<AppUser?> LoginOrCreate(HttpRequest request, ClaimsPrincipal principal)
{ {
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig; var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
@ -83,7 +103,12 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
} }
var user = await unitOfWork.UserRepository.GetByOidcId(oidcId, AppUserIncludes.UserPreferences); var user = await unitOfWork.UserRepository.GetByOidcId(oidcId, AppUserIncludes.UserPreferences);
if (user != null) return user; if (user != null)
{
await SyncUserSettings(request, settings, principal, user);
return user;
}
var email = principal.FindFirstValue(ClaimTypes.Email); var email = principal.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrEmpty(email)) if (string.IsNullOrEmpty(email))
@ -111,6 +136,8 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
user.OidcId = oidcId; user.OidcId = oidcId;
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
await SyncUserSettings(request, settings, principal, user);
return user; return user;
} }
@ -160,16 +187,16 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
try try
{ {
user.UpdateLastActive(); user.UpdateLastActive();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
}
if (unitOfWork.HasChanges()) if (unitOfWork.HasChanges())
{ {
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
} }
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
}
if (string.IsNullOrEmpty(tokenResponse.IdToken)) if (string.IsNullOrEmpty(tokenResponse.IdToken))
{ {
@ -341,9 +368,17 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
} }
/// <summary>
/// Syncs the given user to the principal found in the id token
/// </summary>
/// <param name="ctx"></param>
/// <param name="settings"></param>
/// <param name="idToken"></param>
/// <param name="user"></param>
/// <exception cref="UnauthorizedAccessException">If syncing fails</exception>
private async Task SyncUserSettings(CookieValidatePrincipalContext ctx, OidcConfigDto settings, string idToken, AppUser user) private async Task SyncUserSettings(CookieValidatePrincipalContext ctx, OidcConfigDto settings, string idToken, AppUser user)
{ {
if (!settings.SyncUserSettings) return; if (!settings.SyncUserSettings || user.IdentityProvider != IdentityProvider.OpenIdConnect) return;
try try
{ {
@ -366,7 +401,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
/// <param name="user"></param> /// <param name="user"></param>
public async Task SyncUserSettings(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) public async Task SyncUserSettings(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{ {
if (!settings.SyncUserSettings) return; if (!settings.SyncUserSettings || user.IdentityProvider != IdentityProvider.OpenIdConnect) return;
// Never sync the default user // Never sync the default user
var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser(); var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
@ -565,10 +600,10 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
/// <param name="refreshToken"></param> /// <param name="refreshToken"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="InvalidOperationException"></exception> /// <exception cref="InvalidOperationException"></exception>
private async Task<OpenIdConnectMessage> RefreshTokenAsync(OidcConfigDto dto, string refreshToken) private static async Task<OpenIdConnectMessage> RefreshTokenAsync(OidcConfigDto dto, string refreshToken)
{ {
_discoveryDocument ??= await LoadOidcConfiguration(dto.Authority); var discoveryDocument = await OidcConfigurationManager.GetConfigurationAsync();
var msg = new var msg = new
{ {
@ -578,7 +613,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
client_secret = dto.Secret, client_secret = dto.Secret,
}; };
var json = await _discoveryDocument.TokenEndpoint var json = await discoveryDocument.TokenEndpoint
.AllowAnyHttpStatus() .AllowAnyHttpStatus()
.PostUrlEncodedAsync(msg) .PostUrlEncodedAsync(msg)
.ReceiveString(); .ReceiveString();
@ -593,14 +628,15 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
/// <param name="idToken"></param> /// <param name="idToken"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="InvalidOperationException"></exception> /// <exception cref="InvalidOperationException"></exception>
private async Task<ClaimsPrincipal> ParseIdToken(OidcConfigDto dto, string idToken) private static async Task<ClaimsPrincipal> ParseIdToken(OidcConfigDto dto, string idToken)
{ {
_discoveryDocument ??= await LoadOidcConfiguration(dto.Authority); var discoveryDocument = await OidcConfigurationManager.GetConfigurationAsync();
var tokenValidationParameters = new TokenValidationParameters var tokenValidationParameters = new TokenValidationParameters
{ {
ValidIssuer = _discoveryDocument.Issuer, ValidIssuer = discoveryDocument.Issuer,
ValidAudience = dto.ClientId, ValidAudience = dto.ClientId,
IssuerSigningKeys = _discoveryDocument.SigningKeys, IssuerSigningKeys = discoveryDocument.SigningKeys,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
}; };
@ -610,25 +646,6 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
return principal; return principal;
} }
/// <summary>
/// Loads OpenIdConnectConfiguration, includes <see cref="OpenIdConnectConfiguration.SigningKeys"/>
/// </summary>
/// <param name="authority"></param>
/// <returns></returns>
private static async Task<OpenIdConnectConfiguration> LoadOidcConfiguration(string authority)
{
var hasTrailingSlash = authority.EndsWith('/');
var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration";
var manager = new ConfigurationManager<OpenIdConnectConfiguration>(
url,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever { RequireHttps = url.StartsWith("https") }
);
return await manager.GetConfigurationAsync();
}
/// <summary> /// <summary>
/// Return a list of claims in the same way the NativeJWT token would map them. /// Return a list of claims in the same way the NativeJWT token would map them.
/// Optionally include original claims if the claims are needed later in the pipeline /// Optionally include original claims if the claims are needed later in the pipeline

View File

@ -1806,10 +1806,15 @@ public class ExternalMetadataService : IExternalMetadataService
{ {
// Find highest age rating from mappings // Find highest age rating from mappings
mappings ??= new Dictionary<string, AgeRating>(); mappings ??= new Dictionary<string, AgeRating>();
mappings = mappings.ToDictionary(k => k.Key.ToNormalized(), k => k.Value); mappings = mappings
.GroupBy(m => m.Key.ToNormalized())
.ToDictionary(
g => g.Key,
g => g.Max(m => m.Value)
);
return values return values
.Select(v => mappings.TryGetValue(v.ToNormalized(), out var mapping) ? mapping : AgeRating.Unknown) .Select(v => mappings.GetValueOrDefault(v.ToNormalized(), AgeRating.Unknown))
.DefaultIfEmpty(AgeRating.Unknown) .DefaultIfEmpty(AgeRating.Unknown)
.Max(); .Max();
} }

View File

@ -16,6 +16,8 @@ using API.SignalR;
using Hangfire; using Hangfire;
using Kavita.Common.Helpers; using Kavita.Common.Helpers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Polly;
using Polly.Retry;
namespace API.Services; namespace API.Services;
@ -84,6 +86,8 @@ public class TaskScheduler : ITaskScheduler
public const string KavitaPlusStackSyncId = "kavita+-stack-sync"; public const string KavitaPlusStackSyncId = "kavita+-stack-sync";
public const string KavitaPlusWantToReadSyncId = "kavita+-want-to-read-sync"; public const string KavitaPlusWantToReadSyncId = "kavita+-want-to-read-sync";
private const int BaseRetryDelay = 60; // 1-minute
public static readonly ImmutableArray<string> ScanTasks = public static readonly ImmutableArray<string> ScanTasks =
["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"]; ["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"];
private static readonly ImmutableArray<string> NonCronOptions = ["disabled", "daily", "weekly"]; private static readonly ImmutableArray<string> NonCronOptions = ["disabled", "daily", "weekly"];
@ -94,6 +98,10 @@ public class TaskScheduler : ITaskScheduler
{ {
TimeZone = TimeZoneInfo.Local TimeZone = TimeZoneInfo.Local
}; };
/// <summary>
/// Retry policy, with 3 tries, and a 1-minute base delay
/// </summary>
private readonly AsyncRetryPolicy _defaultRetryPolicy;
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService, public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
@ -123,6 +131,24 @@ public class TaskScheduler : ITaskScheduler
_smartCollectionSyncService = smartCollectionSyncService; _smartCollectionSyncService = smartCollectionSyncService;
_wantToReadSyncService = wantToReadSyncService; _wantToReadSyncService = wantToReadSyncService;
_eventHub = eventHub; _eventHub = eventHub;
_defaultRetryPolicy = Policy
.Handle<Exception>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
{
var delay = BaseRetryDelay * 2 * attempt;
var jitter = Random.Shared.Next(0, delay / 4);
return TimeSpan.FromSeconds(delay + jitter);
},
onRetry: (ex, timeSpan, attempt) =>
{
_logger.LogWarning(ex, "Attempt {Attempt} failed, retrying in {Delay}ms",
attempt, timeSpan.TotalMilliseconds);
}
);
} }
public async Task ScheduleTasks() public async Task ScheduleTasks()
@ -486,10 +512,14 @@ public class TaskScheduler : ITaskScheduler
/// </summary> /// </summary>
// ReSharper disable once MemberCanBePrivate.Global // ReSharper disable once MemberCanBePrivate.Global
public async Task CheckForUpdate() public async Task CheckForUpdate()
{
await _defaultRetryPolicy.ExecuteAsync(async () =>
{ {
var update = await _versionUpdaterService.CheckForUpdate(); var update = await _versionUpdaterService.CheckForUpdate();
if (update == null) return; if (update == null) return;
await _versionUpdaterService.PushUpdate(update); await _versionUpdaterService.PushUpdate(update);
});
} }
public async Task SyncThemes() public async Task SyncThemes()

View File

@ -370,7 +370,7 @@ public class CoverDbService : ICoverDbService
private async Task<string> FallbackToKavitaReaderFavicon(string baseUrl) private async Task<string> FallbackToKavitaReaderFavicon(string baseUrl)
{ {
const string urlsFileName = "publishers.txt"; const string urlsFileName = "urls.txt";
var correctSizeLink = string.Empty; var correctSizeLink = string.Empty;
var allOverrides = await GetCachedData(urlsFileName) ?? var allOverrides = await GetCachedData(urlsFileName) ??
await $"{NewHost}favicons/{urlsFileName}".GetStringAsync(); await $"{NewHost}favicons/{urlsFileName}".GetStringAsync();
@ -384,6 +384,7 @@ public class CoverDbService : ICoverDbService
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
var externalFile = allOverrides var externalFile = allOverrides
.Split("\n") .Split("\n")
.Select(url => url.Trim('\n', '\r')) // Ensure windows line terminators don't mess anything up
.FirstOrDefault(url => .FirstOrDefault(url =>
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
@ -410,6 +411,7 @@ public class CoverDbService : ICoverDbService
var externalFile = allOverrides var externalFile = allOverrides
.Split("\n") .Split("\n")
.Select(url => url.Trim('\n', '\r')) // Ensure windows line terminators don't mess anything up
.Select(publisherLine => .Select(publisherLine =>
{ {
var tokens = publisherLine.Split("|"); var tokens = publisherLine.Split("|");

View File

@ -2,6 +2,7 @@ import {inject, Pipe, PipeTransform} from '@angular/core';
import { CblBookResult } from 'src/app/_models/reading-list/cbl/cbl-book-result'; import { CblBookResult } from 'src/app/_models/reading-list/cbl/cbl-book-result';
import { CblImportReason } from 'src/app/_models/reading-list/cbl/cbl-import-reason.enum'; import { CblImportReason } from 'src/app/_models/reading-list/cbl/cbl-import-reason.enum';
import {TranslocoService} from "@jsverse/transloco"; import {TranslocoService} from "@jsverse/transloco";
import {APP_BASE_HREF} from "@angular/common";
const failIcon = '<i aria-hidden="true" class="reading-list-fail--item fa-solid fa-circle-xmark me-1"></i>'; const failIcon = '<i aria-hidden="true" class="reading-list-fail--item fa-solid fa-circle-xmark me-1"></i>';
const successIcon = '<i aria-hidden="true" class="reading-list-success--item fa-solid fa-circle-check me-1"></i>'; const successIcon = '<i aria-hidden="true" class="reading-list-success--item fa-solid fa-circle-check me-1"></i>';
@ -13,6 +14,7 @@ const successIcon = '<i aria-hidden="true" class="reading-list-success--item fa-
export class CblConflictReasonPipe implements PipeTransform { export class CblConflictReasonPipe implements PipeTransform {
translocoService = inject(TranslocoService); translocoService = inject(TranslocoService);
protected readonly baseUrl = inject(APP_BASE_HREF);
transform(result: CblBookResult): string { transform(result: CblBookResult): string {
switch (result.reason) { switch (result.reason) {
@ -25,7 +27,7 @@ export class CblConflictReasonPipe implements PipeTransform {
case CblImportReason.NameConflict: case CblImportReason.NameConflict:
return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.name-conflict', {readingListName: result.readingListName}); return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.name-conflict', {readingListName: result.readingListName});
case CblImportReason.SeriesCollision: case CblImportReason.SeriesCollision:
return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.series-collision', {seriesLink: `<a href="/library/${result.libraryId}/series/${result.seriesId}" target="_blank">${result.series}</a>`}); return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.series-collision', {seriesLink: `<a href="${this.baseUrl}library/${result.libraryId}/series/${result.seriesId}" target="_blank">${result.series}</a>`});
case CblImportReason.SeriesMissing: case CblImportReason.SeriesMissing:
return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.series-missing', {series: result.series}); return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.series-missing', {series: result.series});
case CblImportReason.VolumeMissing: case CblImportReason.VolumeMissing:

View File

@ -80,7 +80,7 @@
{{t('series-header')}} {{t('series-header')}}
</ng-template> </ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template> <ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank" id="scrobble-history--{{idx}}">{{item.seriesName}}</a> <a href="{{baseUrl}}library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank" id="scrobble-history--{{idx}}">{{item.seriesName}}</a>
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>

View File

@ -24,7 +24,7 @@ import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter"; import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {AsyncPipe} from "@angular/common"; import {APP_BASE_HREF, AsyncPipe} from "@angular/common";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {SelectionModel} from "../../typeahead/_models/selection-model"; import {SelectionModel} from "../../typeahead/_models/selection-model";
@ -56,6 +56,7 @@ export class UserScrobbleHistoryComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService); private readonly toastr = inject(ToastrService);
protected readonly accountService = inject(AccountService); protected readonly accountService = inject(AccountService);
protected readonly baseUrl = inject(APP_BASE_HREF);
tokenExpired = false; tokenExpired = false;
formGroup: FormGroup = new FormGroup({ formGroup: FormGroup = new FormGroup({

View File

@ -39,7 +39,7 @@
</ng-template> </ng-template>
<ng-template let-item="row" ngx-datatable-cell-template> <ng-template let-item="row" ngx-datatable-cell-template>
<app-image [width]="'32px'" [height]="'32px'" [imageUrl]="imageService.getSeriesCoverImage(item.series.id)"></app-image> <app-image [width]="'32px'" [height]="'32px'" [imageUrl]="imageService.getSeriesCoverImage(item.series.id)"></app-image>
<a class="ms-2" [href]="'/library/' + item.series.libraryId + '/series/' + item.series.id" target="_blank">{{item.series.name}}</a> <a class="ms-2" [href]="baseUrl + 'library/' + item.series.libraryId + '/series/' + item.series.id" target="_blank">{{item.series.name}}</a>
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>

View File

@ -18,7 +18,7 @@ import {debounceTime, distinctUntilChanged, switchMap, tap} from "rxjs";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {LibraryNamePipe} from "../../_pipes/library-name.pipe"; import {LibraryNamePipe} from "../../_pipes/library-name.pipe";
import {AsyncPipe} from "@angular/common"; import {APP_BASE_HREF, AsyncPipe} from "@angular/common";
import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {ScanSeriesEvent} from "../../_models/events/scan-series-event"; import {ScanSeriesEvent} from "../../_models/events/scan-series-event";
import {LibraryTypePipe} from "../../_pipes/library-type.pipe"; import {LibraryTypePipe} from "../../_pipes/library-type.pipe";
@ -59,6 +59,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService); private readonly toastr = inject(ToastrService);
protected readonly imageService = inject(ImageService); protected readonly imageService = inject(ImageService);
protected readonly baseUrl = inject(APP_BASE_HREF);
isLoading: boolean = true; isLoading: boolean = true;

View File

@ -25,10 +25,10 @@
<div class="d-flex flex-row justify-content-center align-items-center"> <div class="d-flex flex-row justify-content-center align-items-center">
@switch (member.identityProvider) { @switch (member.identityProvider) {
@case (IdentityProvider.OpenIdConnect) { @case (IdentityProvider.OpenIdConnect) {
<app-image imageUrl="assets/icons/open-id-connect-logo.svg" height="16px" width="16px"></app-image> <app-image [ngbTooltip]="t('identity-provider-oidc-tooltip')" imageUrl="assets/icons/open-id-connect-logo.svg" height="16px" width="16px"></app-image>
} }
@case (IdentityProvider.Kavita) { @case (IdentityProvider.Kavita) {
<app-image imageUrl="assets/icons/favicon-16x16.png" height="16px" width="16px"></app-image> <app-image [ngbTooltip]="t('identity-provider-native-tooltip')" imageUrl="assets/icons/favicon-16x16.png" height="16px" width="16px"></app-image>
} }
} }
</div> </div>

View File

@ -7,7 +7,7 @@
<div subtitle> <div subtitle>
<h6> <h6>
<span>{{t('count', {count: filters.length | number})}}</span> <span>{{t('count', {count: filters.length | number})}}</span>
<a class="ms-2" href="/all-series?name=New%20Filter">{{t('create')}}</a> <a class="ms-2" href="{{baseUrl}}all-series?name=New%20Filter">{{t('create')}}</a>
</h6> </h6>
</div> </div>
</app-side-nav-companion-bar> </app-side-nav-companion-bar>

View File

@ -12,7 +12,7 @@ import {JumpbarService} from "../_services/jumpbar.service";
import {ActionFactoryService} from "../_services/action-factory.service"; import {ActionFactoryService} from "../_services/action-factory.service";
import {ActionService} from "../_services/action.service"; import {ActionService} from "../_services/action.service";
import {ManageSmartFiltersComponent} from "../sidenav/_components/manage-smart-filters/manage-smart-filters.component"; import {ManageSmartFiltersComponent} from "../sidenav/_components/manage-smart-filters/manage-smart-filters.component";
import {DecimalPipe} from "@angular/common"; import {APP_BASE_HREF, DecimalPipe} from "@angular/common";
@Component({ @Component({
selector: 'app-all-filters', selector: 'app-all-filters',
@ -28,6 +28,7 @@ export class AllFiltersComponent implements OnInit {
private readonly filterService = inject(FilterService); private readonly filterService = inject(FilterService);
private readonly actionFactory = inject(ActionFactoryService); private readonly actionFactory = inject(ActionFactoryService);
private readonly actionService = inject(ActionService); private readonly actionService = inject(ActionService);
protected readonly baseUrl = inject(APP_BASE_HREF);
jumpbarKeys: Array<JumpKey> = []; jumpbarKeys: Array<JumpKey> = [];

View File

@ -10,22 +10,25 @@
} }
<app-nav-header></app-nav-header> <app-nav-header></app-nav-header>
<div [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}"> @let sideNavVisible = navService.sideNavVisibility$ | async;
@let sideNavCollapsed = navService.sideNavCollapsed$ | async;
@let usePreferenceSideNav = navService.usePreferenceSideNav$ | async;
@if (navService.sideNavVisibility$ | async) { <div [ngClass]="{'closed' : sideNavCollapsed, 'content-wrapper': sideNavVisible}">
@if(navService.usePreferenceSideNav$ | async) { @if (sideNavVisible) {
@if(usePreferenceSideNav) {
<app-preference-nav></app-preference-nav> <app-preference-nav></app-preference-nav>
} @else { } @else {
<app-side-nav></app-side-nav> <app-side-nav></app-side-nav>
} }
} }
<div class="" [ngClass]="{'g-0': (navService.sideNavVisibility$ | async) === false}"> <div class="" [ngClass]="{'g-0': sideNavVisible === false}">
<a id="content"></a> <a id="content"></a>
@if (navService.sideNavVisibility$ | async) { @if (sideNavVisible) {
<div> <div>
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService.sideNavCollapsed$ | async) === false || (navService.usePreferenceSideNav$ | async), <div class="companion-bar" [ngClass]="{'companion-bar-content': sideNavCollapsed === false || usePreferenceSideNav,
'companion-bar-collapsed': ((navService.sideNavCollapsed$ | async) && (navService.usePreferenceSideNav$ | async))}"> 'companion-bar-collapsed': (sideNavCollapsed && usePreferenceSideNav)}">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>
</div> </div>

View File

@ -18,7 +18,7 @@ import {
SimpleChanges, SimpleChanges,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs'; import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject, tap} from 'rxjs';
import {debounceTime} from 'rxjs/operators'; import {debounceTime} from 'rxjs/operators';
import {ScrollService} from 'src/app/_services/scroll.service'; import {ScrollService} from 'src/app/_services/scroll.service';
import {ReaderService} from '../../../_services/reader.service'; import {ReaderService} from '../../../_services/reader.service';
@ -37,6 +37,14 @@ import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
* How much additional space should pass, past the original bottom of the document height before we trigger the next chapter load * How much additional space should pass, past the original bottom of the document height before we trigger the next chapter load
*/ */
const SPACER_SCROLL_INTO_PX = 200; const SPACER_SCROLL_INTO_PX = 200;
/**
* Default debounce time from scroll and scrollend event listeners
*/
const DEFAULT_SCROLL_DEBOUNCE = 20;
/**
* Safari does not support the scrollEnd event, we can use scroll event with higher debounce time to emulate it
*/
const EMULATE_SCROLL_END_DEBOUNCE = 100;
/** /**
* Bitwise enums for configuring how much debug information we want * Bitwise enums for configuring how much debug information we want
@ -220,14 +228,27 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
* gets promoted to fullscreen. * gets promoted to fullscreen.
*/ */
initScrollHandler() { initScrollHandler() {
//console.log('Setting up Scroll handler on ', this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body); const element = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body;
fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body, 'scroll')
.pipe(debounceTime(20), takeUntilDestroyed(this.destroyRef))
.subscribe((event) => this.handleScrollEvent(event));
fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body, 'scrollend') fromEvent(element, 'scroll')
.pipe(debounceTime(20), takeUntilDestroyed(this.destroyRef)) .pipe(
.subscribe((event) => this.handleScrollEndEvent(event)); debounceTime(DEFAULT_SCROLL_DEBOUNCE),
takeUntilDestroyed(this.destroyRef),
tap((event) => this.handleScrollEvent(event))
)
.subscribe();
const isScrollEndSupported = 'onscrollend' in document;
const scrollEndEvent = isScrollEndSupported ? 'scrollend' : 'scroll';
const scrollEndDebounce = isScrollEndSupported ? DEFAULT_SCROLL_DEBOUNCE : EMULATE_SCROLL_END_DEBOUNCE;
fromEvent(element, scrollEndEvent)
.pipe(
debounceTime(scrollEndDebounce),
takeUntilDestroyed(this.destroyRef),
tap((event) => this.handleScrollEndEvent(event))
)
.subscribe();
} }
ngOnInit(): void { ngOnInit(): void {
@ -629,6 +650,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
* Move to the next chapter and set the page * Move to the next chapter and set the page
*/ */
moveToNextChapter() { moveToNextChapter() {
if (!this.allImagesLoaded) return;
this.setPageNum(this.totalPages); this.setPageNum(this.totalPages);
this.loadNextChapter.emit(); this.loadNextChapter.emit();
} }

View File

@ -1571,6 +1571,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
setPageNum(pageNum: number) { setPageNum(pageNum: number) {
if (pageNum === this.pageNum) return;
this.pageNum = Math.max(Math.min(pageNum, this.maxPages - 1), 0); this.pageNum = Math.max(Math.min(pageNum, this.maxPages - 1), 0);
this.pageNumSubject.next({pageNum: this.pageNum, maxPages: this.maxPages}); this.pageNumSubject.next({pageNum: this.pageNum, maxPages: this.maxPages});
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View File

@ -39,7 +39,7 @@
</h5> </h5>
<div class="ps-1 d-none d-md-inline-block mb-1"> <div class="ps-1 d-none d-md-inline-block mb-1">
<app-series-format [format]="item.seriesFormat"></app-series-format> <app-series-format [format]="item.seriesFormat"></app-series-format>
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a> <a href="{{baseUrl}}library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
</div> </div>
<app-read-more [text]="item.summary || ''" [showToggle]="false" [maxLength]="500"></app-read-more> <app-read-more [text]="item.summary || ''" [showToggle]="false" [maxLength]="500"></app-read-more>

View File

@ -4,7 +4,7 @@ import {MangaFormat} from 'src/app/_models/manga-format';
import {ReadingListItem} from 'src/app/_models/reading-list'; import {ReadingListItem} from 'src/app/_models/reading-list';
import {ImageService} from 'src/app/_services/image.service'; import {ImageService} from 'src/app/_services/image.service';
import {NgbProgressbar} from '@ng-bootstrap/ng-bootstrap'; import {NgbProgressbar} from '@ng-bootstrap/ng-bootstrap';
import {DatePipe} from '@angular/common'; import {APP_BASE_HREF, DatePipe} from '@angular/common';
import {ImageComponent} from '../../../shared/image/image.component'; import {ImageComponent} from '../../../shared/image/image.component';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component"; import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
@ -22,6 +22,7 @@ export class ReadingListItemComponent {
protected readonly imageService = inject(ImageService); protected readonly imageService = inject(ImageService);
protected readonly MangaFormat = MangaFormat; protected readonly MangaFormat = MangaFormat;
protected readonly baseUrl = inject(APP_BASE_HREF);
@Input({required: true}) item!: ReadingListItem; @Input({required: true}) item!: ReadingListItem;
@Input() position: number = 0; @Input() position: number = 0;

View File

@ -141,7 +141,7 @@ export class LibrarySettingsModalComponent implements OnInit {
get IsKavitaPlusEligible() { get IsKavitaPlusEligible() {
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType; const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
return allKavitaPlusScrobbleEligibleTypes.includes(libType); return allKavitaPlusMetadataApplicableTypes.includes(libType);
} }
get IsMetadataDownloadEligible() { get IsMetadataDownloadEligible() {

View File

@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit, signal} from '@angular/core';
import {ReadingProfileService} from "../../_services/reading-profile.service"; import {ReadingProfileService} from "../../_services/reading-profile.service";
import { import {
bookLayoutModes, bookLayoutModes,
@ -19,7 +19,7 @@ import {NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {debounceTime, distinctUntilChanged, take, tap} from "rxjs/operators"; import {debounceTime, distinctUntilChanged, map, take, tap} from "rxjs/operators";
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
import {BookService} from "../../book-reader/_services/book.service"; import {BookService} from "../../book-reader/_services/book.service";
@ -42,7 +42,7 @@ import {SettingSwitchComponent} from "../../settings/_components/setting-switch/
import {WritingStylePipe} from "../../_pipes/writing-style.pipe"; import {WritingStylePipe} from "../../_pipes/writing-style.pipe";
import {ColorPickerDirective} from "ngx-color-picker"; import {ColorPickerDirective} from "ngx-color-picker";
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavLinkBase, NgbNavOutlet, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {NgbNav, NgbNavContent, NgbNavItem, NgbNavLinkBase, NgbNavOutlet, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {filter} from "rxjs"; import {catchError, filter, finalize, of, switchMap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {LoadingComponent} from "../../shared/loading/loading.component"; import {LoadingComponent} from "../../shared/loading/loading.component";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
@ -95,8 +95,19 @@ enum TabId {
}) })
export class ManageReadingProfilesComponent implements OnInit { export class ManageReadingProfilesComponent implements OnInit {
private readonly readingProfileService = inject(ReadingProfileService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService);
private readonly bookService = inject(BookService);
private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService);
private readonly transLoco = inject(TranslocoService);
virtualScrollerBreakPoint = 20; virtualScrollerBreakPoint = 20;
savingProfile = signal(false);
fontFamilies: Array<string> = []; fontFamilies: Array<string> = [];
readingProfiles: ReadingProfile[] = []; readingProfiles: ReadingProfile[] = [];
user!: User; user!: User;
@ -111,16 +122,7 @@ export class ManageReadingProfilesComponent implements OnInit {
return d; return d;
}); });
constructor( constructor() {
private readingProfileService: ReadingProfileService,
private cdRef: ChangeDetectorRef,
private accountService: AccountService,
private bookService: BookService,
private destroyRef: DestroyRef,
private toastr: ToastrService,
private confirmService: ConfirmService,
private transLoco: TranslocoService,
) {
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title); this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
@ -219,41 +221,49 @@ export class ManageReadingProfilesComponent implements OnInit {
this.readingProfileForm.valueChanges.pipe( this.readingProfileForm.valueChanges.pipe(
debounceTime(500), debounceTime(500),
distinctUntilChanged(), distinctUntilChanged(),
filter(_ => !this.savingProfile()),
filter(_ => this.readingProfileForm!.valid), filter(_ => this.readingProfileForm!.valid),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
tap(_ => this.autoSave()), tap(_ => this.savingProfile.set(true)),
switchMap(_ => this.autoSave()),
finalize(() => this.savingProfile.set(false))
).subscribe(); ).subscribe();
} }
private autoSave() { private autoSave() {
if (this.selectedProfile!.id == 0) { if (this.selectedProfile!.id == 0) {
this.readingProfileService.createProfile(this.packData()).subscribe({ return this.readingProfileService.createProfile(this.packData()).pipe(
next: createdProfile => { tap(createdProfile => {
this.selectedProfile = createdProfile; this.selectedProfile = createdProfile;
this.readingProfiles.push(createdProfile); this.readingProfiles.push(createdProfile);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}, }),
error: err => { catchError(err => {
console.log(err); console.log(err);
this.toastr.error(err.message); this.toastr.error(err.message);
}
return of(null);
}) })
} else { );
}
const profile = this.packData(); const profile = this.packData();
this.readingProfileService.updateProfile(profile).subscribe({ return this.readingProfileService.updateProfile(profile).pipe(
next: newProfile => { tap(newProfile => {
this.readingProfiles = this.readingProfiles.map(p => { this.readingProfiles = this.readingProfiles.map(p => {
if (p.id !== profile.id) return p; if (p.id !== profile.id) return p;
return newProfile; return newProfile;
}); });
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}, }),
error: err => { catchError(err => {
console.log(err); console.log(err);
this.toastr.error(err.message); this.toastr.error(err.message);
}
return of(null);
}) })
} );
} }
private packData(): ReadingProfile { private packData(): ReadingProfile {

View File

@ -17,7 +17,7 @@
</ng-template> </ng-template>
<ng-template let-item="row" ngx-datatable-cell-template> <ng-template let-item="row" ngx-datatable-cell-template>
<app-image [width]="'32px'" [height]="'32px'" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image> <app-image [width]="'32px'" [height]="'32px'" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
<a class="btn-link ms-2" href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a> <a class="btn-link ms-2" href="{{baseUrl}}library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>

View File

@ -6,6 +6,7 @@ import {ImageComponent} from "../../shared/image/image.component";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {ScrobbleHold} from "../../_models/scrobbling/scrobble-hold"; import {ScrobbleHold} from "../../_models/scrobbling/scrobble-hold";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {APP_BASE_HREF} from "@angular/common";
@Component({ @Component({
selector: 'app-user-holds', selector: 'app-user-holds',
@ -20,6 +21,7 @@ export class ScrobblingHoldsComponent {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly scrobblingService = inject(ScrobblingService); private readonly scrobblingService = inject(ScrobblingService);
protected readonly imageService = inject(ImageService); protected readonly imageService = inject(ImageService);
protected readonly baseUrl = inject(APP_BASE_HREF);
isLoading = true; isLoading = true;
data: Array<ScrobbleHold> = []; data: Array<ScrobbleHold> = [];

View File

@ -29,7 +29,7 @@
"manual-save-label": "Changing provider settings requires a manual save", "manual-save-label": "Changing provider settings requires a manual save",
"authority-label": "Authority", "authority-label": "Authority",
"authority-tooltip": "The URL to your OIDC provider", "authority-tooltip": "The URL to your OIDC provider, do not include the .well-known/openid-configuration path",
"client-id-label": "Client ID", "client-id-label": "Client ID",
"client-id-tooltip": "The ClientID set in your OIDC provider, can be anything", "client-id-tooltip": "The ClientID set in your OIDC provider, can be anything",
"secret-label": "Client secret", "secret-label": "Client secret",
@ -1186,7 +1186,7 @@
"type-label": "Type", "type-label": "Type",
"type-tooltip": "Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Check the wiki for more details on the differences between the library types.", "type-tooltip": "Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Check the wiki for more details on the differences between the library types.",
"kavitaplus-eligible-label": "Kavita+ Eligible", "kavitaplus-eligible-label": "Kavita+ Eligible",
"kavitaplus-eligible-tooltip": "Supports Kavita+ metadata features or Scrobbling", "kavitaplus-eligible-tooltip": "Supports Kavita+ metadata features and/or Scrobbling",
"folder-description": "Add folders to your library", "folder-description": "Add folders to your library",
"browse": "Browse for Media Folders", "browse": "Browse for Media Folders",
"help-us-part-1": "Help us out by following ", "help-us-part-1": "Help us out by following ",
@ -1724,7 +1724,9 @@
"no-data": "There are no other users.", "no-data": "There are no other users.",
"loading": "{{common.loading}}", "loading": "{{common.loading}}",
"actions-header": "Actions", "actions-header": "Actions",
"pending-tooltip": "This user has not validated their email" "pending-tooltip": "This user has not validated their email",
"identity-provider-oidc-tooltip": "OIDC",
"identity-provider-native-tooltip": "Native"
}, },
"role-localized-pipe": { "role-localized-pipe": {