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();
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,

View File

@ -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" />

View File

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

View File

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

View File

@ -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();
}

View File

@ -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()

View File

@ -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("|");

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 { 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:

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

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

View File

@ -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> = [];

View File

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

View File

@ -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();
}

View File

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

View File

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

View File

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

View File

@ -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() {

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 {
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 {

View File

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

View File

@ -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> = [];

View File

@ -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": {