mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-08-07 09:01:25 -04:00
A few bug fixes (#3980)
This commit is contained in:
parent
b0059128b3
commit
881727bd21
19
.github/copilot-instructions.md
vendored
Normal file
19
.github/copilot-instructions.md
vendored
Normal 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
|
@ -28,7 +28,9 @@ public class OidcServiceTests: AbstractDbTest
|
||||
await ResetDb();
|
||||
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);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
@ -426,7 +428,9 @@ public class OidcServiceTests: AbstractDbTest
|
||||
Assert.NotNull(userFromDb);
|
||||
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);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
@ -515,7 +519,9 @@ public class OidcServiceTests: AbstractDbTest
|
||||
var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost")
|
||||
.WithRole(PolicyConstants.AdminRole)
|
||||
.Build();
|
||||
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
|
||||
var user = new AppUserBuilder("amelia", "amelia@localhost")
|
||||
.WithIdentityProvider(IdentityProvider.OpenIdConnect)
|
||||
.Build();
|
||||
|
||||
var roleStore = new RoleStore<
|
||||
AppRole,
|
||||
|
@ -80,6 +80,7 @@
|
||||
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
||||
<PackageReference Include="NetVips" Version="3.1.0" />
|
||||
<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.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
|
@ -2,6 +2,7 @@
|
||||
using System.Linq;
|
||||
using API.Data;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using Kavita.Common;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
@ -68,4 +69,10 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
|
||||
_appUser.UserRoles.Add(new AppUserRole() {Role = new AppRole() {Name = role}});
|
||||
return this;
|
||||
}
|
||||
|
||||
public AppUserBuilder WithIdentityProvider(IdentityProvider identityProvider)
|
||||
{
|
||||
_appUser.IdentityProvider = identityProvider;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
@ -68,10 +68,30 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
/// The name of the Auth Cookie set by .NET
|
||||
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, 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)
|
||||
{
|
||||
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);
|
||||
if (user != null) return user;
|
||||
if (user != null)
|
||||
{
|
||||
await SyncUserSettings(request, settings, principal, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
var email = principal.FindFirstValue(ClaimTypes.Email);
|
||||
if (string.IsNullOrEmpty(email))
|
||||
@ -111,6 +136,8 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
user.OidcId = oidcId;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await SyncUserSettings(request, settings, principal, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@ -160,17 +187,17 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
try
|
||||
{
|
||||
user.UpdateLastActive();
|
||||
|
||||
if (unitOfWork.HasChanges())
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
|
||||
}
|
||||
|
||||
if (unitOfWork.HasChanges())
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(tokenResponse.IdToken))
|
||||
{
|
||||
logger.LogTrace("The OIDC provider did not return an id token in the refresh response, continuous sync is not supported");
|
||||
@ -341,9 +368,17 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
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)
|
||||
{
|
||||
if (!settings.SyncUserSettings) return;
|
||||
if (!settings.SyncUserSettings || user.IdentityProvider != IdentityProvider.OpenIdConnect) return;
|
||||
|
||||
try
|
||||
{
|
||||
@ -366,7 +401,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
/// <param name="user"></param>
|
||||
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
|
||||
var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
@ -565,10 +600,10 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
/// <param name="refreshToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <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
|
||||
{
|
||||
@ -578,7 +613,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
client_secret = dto.Secret,
|
||||
};
|
||||
|
||||
var json = await _discoveryDocument.TokenEndpoint
|
||||
var json = await discoveryDocument.TokenEndpoint
|
||||
.AllowAnyHttpStatus()
|
||||
.PostUrlEncodedAsync(msg)
|
||||
.ReceiveString();
|
||||
@ -593,14 +628,15 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
/// <param name="idToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <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
|
||||
{
|
||||
ValidIssuer = _discoveryDocument.Issuer,
|
||||
ValidIssuer = discoveryDocument.Issuer,
|
||||
ValidAudience = dto.ClientId,
|
||||
IssuerSigningKeys = _discoveryDocument.SigningKeys,
|
||||
IssuerSigningKeys = discoveryDocument.SigningKeys,
|
||||
ValidateIssuerSigningKey = true,
|
||||
};
|
||||
|
||||
@ -610,25 +646,6 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
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>
|
||||
/// 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
|
||||
|
@ -1806,10 +1806,15 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
{
|
||||
// Find highest age rating from mappings
|
||||
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
|
||||
.Select(v => mappings.TryGetValue(v.ToNormalized(), out var mapping) ? mapping : AgeRating.Unknown)
|
||||
.Select(v => mappings.GetValueOrDefault(v.ToNormalized(), AgeRating.Unknown))
|
||||
.DefaultIfEmpty(AgeRating.Unknown)
|
||||
.Max();
|
||||
}
|
||||
|
@ -16,6 +16,8 @@ using API.SignalR;
|
||||
using Hangfire;
|
||||
using Kavita.Common.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
@ -84,6 +86,8 @@ public class TaskScheduler : ITaskScheduler
|
||||
public const string KavitaPlusStackSyncId = "kavita+-stack-sync";
|
||||
public const string KavitaPlusWantToReadSyncId = "kavita+-want-to-read-sync";
|
||||
|
||||
private const int BaseRetryDelay = 60; // 1-minute
|
||||
|
||||
public static readonly ImmutableArray<string> ScanTasks =
|
||||
["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"];
|
||||
private static readonly ImmutableArray<string> NonCronOptions = ["disabled", "daily", "weekly"];
|
||||
@ -94,6 +98,10 @@ public class TaskScheduler : ITaskScheduler
|
||||
{
|
||||
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,
|
||||
@ -123,6 +131,24 @@ public class TaskScheduler : ITaskScheduler
|
||||
_smartCollectionSyncService = smartCollectionSyncService;
|
||||
_wantToReadSyncService = wantToReadSyncService;
|
||||
_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()
|
||||
@ -487,9 +513,13 @@ public class TaskScheduler : ITaskScheduler
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public async Task CheckForUpdate()
|
||||
{
|
||||
var update = await _versionUpdaterService.CheckForUpdate();
|
||||
if (update == null) return;
|
||||
await _versionUpdaterService.PushUpdate(update);
|
||||
await _defaultRetryPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
var update = await _versionUpdaterService.CheckForUpdate();
|
||||
if (update == null) return;
|
||||
|
||||
await _versionUpdaterService.PushUpdate(update);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SyncThemes()
|
||||
|
@ -370,7 +370,7 @@ public class CoverDbService : ICoverDbService
|
||||
|
||||
private async Task<string> FallbackToKavitaReaderFavicon(string baseUrl)
|
||||
{
|
||||
const string urlsFileName = "publishers.txt";
|
||||
const string urlsFileName = "urls.txt";
|
||||
var correctSizeLink = string.Empty;
|
||||
var allOverrides = await GetCachedData(urlsFileName) ??
|
||||
await $"{NewHost}favicons/{urlsFileName}".GetStringAsync();
|
||||
@ -384,6 +384,7 @@ public class CoverDbService : ICoverDbService
|
||||
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.Select(url => url.Trim('\n', '\r')) // Ensure windows line terminators don't mess anything up
|
||||
.FirstOrDefault(url =>
|
||||
cleanedBaseUrl.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
|
||||
.Split("\n")
|
||||
.Select(url => url.Trim('\n', '\r')) // Ensure windows line terminators don't mess anything up
|
||||
.Select(publisherLine =>
|
||||
{
|
||||
var tokens = publisherLine.Split("|");
|
||||
|
@ -2,6 +2,7 @@ import {inject, Pipe, PipeTransform} from '@angular/core';
|
||||
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 {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 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 {
|
||||
|
||||
translocoService = inject(TranslocoService);
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
|
||||
transform(result: CblBookResult): string {
|
||||
switch (result.reason) {
|
||||
@ -25,7 +27,7 @@ export class CblConflictReasonPipe implements PipeTransform {
|
||||
case CblImportReason.NameConflict:
|
||||
return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.name-conflict', {readingListName: result.readingListName});
|
||||
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:
|
||||
return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.series-missing', {series: result.series});
|
||||
case CblImportReason.VolumeMissing:
|
||||
|
@ -80,7 +80,7 @@
|
||||
{{t('series-header')}}
|
||||
</ng-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>
|
||||
</ngx-datatable-column>
|
||||
|
||||
|
@ -24,7 +24,7 @@ import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter";
|
||||
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 {ToastrService} from "ngx-toastr";
|
||||
import {SelectionModel} from "../../typeahead/_models/selection-model";
|
||||
@ -56,6 +56,7 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
|
||||
tokenExpired = false;
|
||||
formGroup: FormGroup = new FormGroup({
|
||||
|
@ -39,7 +39,7 @@
|
||||
</ng-template>
|
||||
<ng-template let-item="row" ngx-datatable-cell-template>
|
||||
<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>
|
||||
</ngx-datatable-column>
|
||||
|
||||
|
@ -18,7 +18,7 @@ import {debounceTime, distinctUntilChanged, switchMap, tap} from "rxjs";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
|
||||
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 {ScanSeriesEvent} from "../../_models/events/scan-series-event";
|
||||
import {LibraryTypePipe} from "../../_pipes/library-type.pipe";
|
||||
@ -59,6 +59,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
|
||||
|
||||
isLoading: boolean = true;
|
||||
|
@ -25,10 +25,10 @@
|
||||
<div class="d-flex flex-row justify-content-center align-items-center">
|
||||
@switch (member.identityProvider) {
|
||||
@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) {
|
||||
<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>
|
||||
|
@ -7,7 +7,7 @@
|
||||
<div subtitle>
|
||||
<h6>
|
||||
<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>
|
||||
</div>
|
||||
</app-side-nav-companion-bar>
|
||||
|
@ -12,7 +12,7 @@ import {JumpbarService} from "../_services/jumpbar.service";
|
||||
import {ActionFactoryService} from "../_services/action-factory.service";
|
||||
import {ActionService} from "../_services/action.service";
|
||||
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({
|
||||
selector: 'app-all-filters',
|
||||
@ -28,6 +28,7 @@ export class AllFiltersComponent implements OnInit {
|
||||
private readonly filterService = inject(FilterService);
|
||||
private readonly actionFactory = inject(ActionFactoryService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
|
||||
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
|
@ -10,22 +10,25 @@
|
||||
}
|
||||
<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) {
|
||||
@if(navService.usePreferenceSideNav$ | async) {
|
||||
<div [ngClass]="{'closed' : sideNavCollapsed, 'content-wrapper': sideNavVisible}">
|
||||
@if (sideNavVisible) {
|
||||
@if(usePreferenceSideNav) {
|
||||
<app-preference-nav></app-preference-nav>
|
||||
} @else {
|
||||
<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>
|
||||
@if (navService.sideNavVisibility$ | async) {
|
||||
@if (sideNavVisible) {
|
||||
<div>
|
||||
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService.sideNavCollapsed$ | async) === false || (navService.usePreferenceSideNav$ | async),
|
||||
'companion-bar-collapsed': ((navService.sideNavCollapsed$ | async) && (navService.usePreferenceSideNav$ | async))}">
|
||||
<div class="companion-bar" [ngClass]="{'companion-bar-content': sideNavCollapsed === false || usePreferenceSideNav,
|
||||
'companion-bar-collapsed': (sideNavCollapsed && usePreferenceSideNav)}">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -18,7 +18,7 @@ import {
|
||||
SimpleChanges,
|
||||
ViewChild
|
||||
} 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 {ScrollService} from 'src/app/_services/scroll.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
|
||||
*/
|
||||
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
|
||||
@ -220,14 +228,27 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
* gets promoted to fullscreen.
|
||||
*/
|
||||
initScrollHandler() {
|
||||
//console.log('Setting up Scroll handler on ', 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));
|
||||
const element = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body;
|
||||
|
||||
fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body, 'scrollend')
|
||||
.pipe(debounceTime(20), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((event) => this.handleScrollEndEvent(event));
|
||||
fromEvent(element, 'scroll')
|
||||
.pipe(
|
||||
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 {
|
||||
@ -629,6 +650,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
* Move to the next chapter and set the page
|
||||
*/
|
||||
moveToNextChapter() {
|
||||
if (!this.allImagesLoaded) return;
|
||||
|
||||
this.setPageNum(this.totalPages);
|
||||
this.loadNextChapter.emit();
|
||||
}
|
||||
|
@ -1571,6 +1571,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
setPageNum(pageNum: number) {
|
||||
if (pageNum === this.pageNum) return;
|
||||
|
||||
this.pageNum = Math.max(Math.min(pageNum, this.maxPages - 1), 0);
|
||||
this.pageNumSubject.next({pageNum: this.pageNum, maxPages: this.maxPages});
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -39,7 +39,7 @@
|
||||
</h5>
|
||||
<div class="ps-1 d-none d-md-inline-block mb-1">
|
||||
<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>
|
||||
|
||||
<app-read-more [text]="item.summary || ''" [showToggle]="false" [maxLength]="500"></app-read-more>
|
||||
|
@ -4,7 +4,7 @@ import {MangaFormat} from 'src/app/_models/manga-format';
|
||||
import {ReadingListItem} from 'src/app/_models/reading-list';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
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 {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
|
||||
@ -22,6 +22,7 @@ export class ReadingListItemComponent {
|
||||
|
||||
protected readonly imageService = inject(ImageService);
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
|
||||
@Input({required: true}) item!: ReadingListItem;
|
||||
@Input() position: number = 0;
|
||||
|
@ -141,7 +141,7 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
|
||||
get IsKavitaPlusEligible() {
|
||||
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
|
||||
return allKavitaPlusScrobbleEligibleTypes.includes(libType);
|
||||
return allKavitaPlusMetadataApplicableTypes.includes(libType);
|
||||
}
|
||||
|
||||
get IsMetadataDownloadEligible() {
|
||||
|
@ -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 {
|
||||
bookLayoutModes,
|
||||
@ -19,7 +19,7 @@ import {NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
|
||||
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||
import {User} from "../../_models/user";
|
||||
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 {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
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 {ColorPickerDirective} from "ngx-color-picker";
|
||||
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 {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
@ -95,8 +95,19 @@ enum TabId {
|
||||
})
|
||||
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;
|
||||
|
||||
savingProfile = signal(false);
|
||||
|
||||
fontFamilies: Array<string> = [];
|
||||
readingProfiles: ReadingProfile[] = [];
|
||||
user!: User;
|
||||
@ -111,16 +122,7 @@ export class ManageReadingProfilesComponent implements OnInit {
|
||||
return d;
|
||||
});
|
||||
|
||||
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,
|
||||
) {
|
||||
constructor() {
|
||||
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
@ -219,41 +221,49 @@ export class ManageReadingProfilesComponent implements OnInit {
|
||||
this.readingProfileForm.valueChanges.pipe(
|
||||
debounceTime(500),
|
||||
distinctUntilChanged(),
|
||||
filter(_ => !this.savingProfile()),
|
||||
filter(_ => this.readingProfileForm!.valid),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
tap(_ => this.autoSave()),
|
||||
tap(_ => this.savingProfile.set(true)),
|
||||
switchMap(_ => this.autoSave()),
|
||||
finalize(() => this.savingProfile.set(false))
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
private autoSave() {
|
||||
if (this.selectedProfile!.id == 0) {
|
||||
this.readingProfileService.createProfile(this.packData()).subscribe({
|
||||
next: createdProfile => {
|
||||
return this.readingProfileService.createProfile(this.packData()).pipe(
|
||||
tap(createdProfile => {
|
||||
this.selectedProfile = createdProfile;
|
||||
this.readingProfiles.push(createdProfile);
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: err => {
|
||||
}),
|
||||
catchError(err => {
|
||||
console.log(err);
|
||||
this.toastr.error(err.message);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const profile = this.packData();
|
||||
this.readingProfileService.updateProfile(profile).subscribe({
|
||||
next: newProfile => {
|
||||
this.readingProfiles = this.readingProfiles.map(p => {
|
||||
if (p.id !== profile.id) return p;
|
||||
return newProfile;
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: err => {
|
||||
console.log(err);
|
||||
this.toastr.error(err.message);
|
||||
}
|
||||
})
|
||||
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const profile = this.packData();
|
||||
return this.readingProfileService.updateProfile(profile).pipe(
|
||||
tap(newProfile => {
|
||||
this.readingProfiles = this.readingProfiles.map(p => {
|
||||
if (p.id !== profile.id) return p;
|
||||
|
||||
return newProfile;
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
catchError(err => {
|
||||
console.log(err);
|
||||
this.toastr.error(err.message);
|
||||
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private packData(): ReadingProfile {
|
||||
|
@ -17,7 +17,7 @@
|
||||
</ng-template>
|
||||
<ng-template let-item="row" ngx-datatable-cell-template>
|
||||
<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>
|
||||
</ngx-datatable-column>
|
||||
|
||||
|
@ -6,6 +6,7 @@ import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {ScrobbleHold} from "../../_models/scrobbling/scrobble-hold";
|
||||
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
|
||||
import {APP_BASE_HREF} from "@angular/common";
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-holds',
|
||||
@ -20,6 +21,7 @@ export class ScrobblingHoldsComponent {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly scrobblingService = inject(ScrobblingService);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
|
||||
isLoading = true;
|
||||
data: Array<ScrobbleHold> = [];
|
||||
|
@ -29,7 +29,7 @@
|
||||
"manual-save-label": "Changing provider settings requires a manual save",
|
||||
|
||||
"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-tooltip": "The ClientID set in your OIDC provider, can be anything",
|
||||
"secret-label": "Client secret",
|
||||
@ -1186,7 +1186,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
"browse": "Browse for Media Folders",
|
||||
"help-us-part-1": "Help us out by following ",
|
||||
@ -1724,7 +1724,9 @@
|
||||
"no-data": "There are no other users.",
|
||||
"loading": "{{common.loading}}",
|
||||
"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": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user