This commit is contained in:
Joe Milazzo
2026-03-12 13:06:43 -05:00
committed by GitHub
parent 8880a590e6
commit e57ac309eb
34 changed files with 303 additions and 184 deletions
+2 -2
View File
@@ -30,13 +30,13 @@ jobs:
run: dotnet restore
- name: Build project
run: dotnet build API/API.csproj --configuration Debug --output ./build-output
run: dotnet build Kavita.Server/Kavita.Server.csproj --configuration Debug --output ./build-output
- name: Get Swashbuckle version
id: swashbuckle-version
run: |
VERSION=$(grep -o '<PackageReference Include="Swashbuckle.AspNetCore" Version="[^"]*"' API/API.csproj | grep -o 'Version="[^"]*"' | cut -d'"' -f2)
VERSION=$(grep -o '<PackageReference Include="Swashbuckle.AspNetCore" Version="[^"]*"' Kavita.Server/Kavita.Server.csproj | grep -o 'Version="[^"]*"' | cut -d'"' -f2)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Found Swashbuckle.AspNetCore version: $VERSION"
@@ -78,7 +78,6 @@ public interface IUserRepository
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo(CancellationToken ct = default);
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail, CancellationToken ct = default);
Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None, CancellationToken ct = default);
Task UpdateUserAsActive(int userId, CancellationToken ct = default);
#endregion
#region Ratings & Reviews
@@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
namespace Kavita.API.Services;
public interface IActiveUserTrackerService
{
void RecordActive(int userId);
Task FlushAsync(CancellationToken ct = default);
}
-2
View File
@@ -46,12 +46,10 @@ public class RateLimiter(int maxRequests, TimeSpan duration, bool refillBetween
if (timeSinceLastRefill >= duration)
{
_tokenBuckets[key] = (Tokens: maxRequests, LastRefill: now);
Console.WriteLine($"Tokens Refilled to Max: {maxRequests}");
}
else if (tokensToAdd > 0 && refillBetween)
{
_tokenBuckets[key] = (Tokens: Math.Min(maxRequests, _tokenBuckets[key].Tokens + tokensToAdd), LastRefill: now);
Console.WriteLine($"Tokens Refilled: {_tokenBuckets[key].Tokens}");
}
}
}
+4 -12
View File
@@ -607,17 +607,8 @@ public class UserRepository(DataContext context, UserManager<AppUser> userManage
.ToListAsync(ct);
}
public async Task UpdateUserAsActive(int userId, CancellationToken ct = default)
{
await context.Set<AppUser>()
.Where(u => u.Id == userId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(u => u.LastActiveUtc, DateTime.UtcNow)
.SetProperty(u => u.LastActive, DateTime.Now), ct);
}
/// <summary>
/// Retrieve all reviews (series and chapter) for a given user, respecting profile privacy settings and age restrictions.
/// <summary> Retrieve all reviews (series and chapter) for a given user, respecting profile privacy settings and age restrictions.
/// </summary>
/// <param name="userId">UserId of source user</param>
/// <param name="requestingUserId">Viewer UserId</param>
@@ -625,7 +616,8 @@ public class UserRepository(DataContext context, UserManager<AppUser> userManage
/// <param name="ratingFilter">Rating, only applies to series/chapters rated. Will show everything greater or equal to</param>
/// <param name="ct"></param>
/// <returns></returns>
public async Task<IList<UserReviewExtendedDto>> GetAllReviewsForUser(int userId, int requestingUserId, string? query = null, float? ratingFilter = null, CancellationToken ct = default)
public async Task<IList<UserReviewExtendedDto>> GetAllReviewsForUser(int userId, int requestingUserId,
string? query = null, float? ratingFilter = null, CancellationToken ct = default)
{
var bypassPreferences = userId == requestingUserId;
if (!bypassPreferences)
@@ -639,7 +631,7 @@ public class UserRepository(DataContext context, UserManager<AppUser> userManage
}
}
var userRating = await context.AppUser.GetUserAgeRestriction(requestingUserId);
var userRating = await context.AppUser.GetUserAgeRestriction(requestingUserId, ct: ct);
// Get series-level reviews
var seriesReviews = await context.AppUserRating
@@ -23,4 +23,5 @@ public static class TaskSchedulerConstants
public const string ReadingHistoryAggregationId = "reading-history-aggregation";
public const string AuthKeyExpirationId = "auth-key-expiration";
public const string EnsureSideNavId = "ensure-sidenav";
public const string FlushUserActiveTaskId = "flush-user-active";
}
@@ -114,7 +114,6 @@ public class ReadingListController(
{
return BadRequest(await localizationService.Translate(UserId, "reading-list-permission"));
}
if (await readingListService.UpdateReadingListItemPosition(dto)) return Ok(await localizationService.Translate(UserId, "reading-list-updated"));
@@ -1,6 +1,6 @@
using System;
using System;
using System.Threading.Tasks;
using Kavita.API.Database;
using Kavita.API.Services;
using Kavita.API.Store;
using Kavita.Models.Entities.User;
using Microsoft.AspNetCore.Http;
@@ -14,14 +14,14 @@ namespace Kavita.Server.Middleware;
/// <param name="next"></param>
public class UpdateUserAsActiveMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, IUserContext userContext, IUnitOfWork unitOfWork)
public async Task InvokeAsync(HttpContext context, IUserContext userContext, IActiveUserTrackerService tracker)
{
try
{
var userId = userContext.GetUserId();
if (userId > 0)
{
await unitOfWork.UserRepository.UpdateUserAsActive(userId.Value);
tracker.RecordActive(userId.Value);
}
}
catch (Exception)
@@ -0,0 +1,77 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Kavita.API.Database;
using Kavita.API.Services;
using Kavita.Common.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Kavita.Services;
/// <summary>
/// Responsible to track userId via <see cref="UpdateUserAsActiveMiddleware"/>. Flushes the queue every 5 mins.
/// </summary>
public sealed class ActiveUserTrackerService( IServiceScopeFactory serviceScopeFactory, ILogger<ActiveUserTrackerService> logger)
: IActiveUserTrackerService, IHostedService
{
private readonly RateLimiter _rateLimiter = new(1, TimeSpan.FromMinutes(5), refillBetween: false);
private readonly ConcurrentDictionary<int, DateTime> _pendingUpdates = new();
/// <summary>
/// Enqueue that the userId was seen
/// </summary>
/// <param name="userId"></param>
public void RecordActive(int userId)
{
if (_rateLimiter.TryAcquire(userId.ToString()))
{
_pendingUpdates[userId] = DateTime.UtcNow;
}
}
/// <summary>
/// Flush the queue of userId's to the DB.
/// </summary>
/// <remarks>Expected to be run by Hangfire, not invoked manually</remarks>
/// <param name="ct"></param>
public async Task FlushAsync(CancellationToken ct = default)
{
if (_pendingUpdates.IsEmpty) return;
var userIds = _pendingUpdates.Keys.ToList();
try
{
using var scope = serviceScopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IDataContext>();
await context.Users
.Where(u => userIds.Contains(u.Id))
.ExecuteUpdateAsync(setters => setters
.SetProperty(u => u.LastActiveUtc, DateTime.UtcNow)
.SetProperty(u => u.LastActive, DateTime.Now), ct);
foreach (var key in userIds)
{
_pendingUpdates.TryRemove(key, out _);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to bulk update LastActive for {Count} users", userIds.Count);
}
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public async Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Flushing pending LastActive updates before shutdown");
await FlushAsync(cancellationToken);
}
}
@@ -93,6 +93,9 @@ public static class ApplicationServiceExtensions
services.AddSingleton<IReadingSessionService, ReadingSessionService>();
services.AddSingleton<IEntityNamingService, EntityNamingService>();
services.AddSingleton<ActiveUserTrackerService>(); // This is required for the below lines. It allows IHostedService.StopAsync() to be called on shutdown
services.AddSingleton<IActiveUserTrackerService>(sp => sp.GetRequiredService<ActiveUserTrackerService>());
services.AddHostedService(sp => sp.GetRequiredService<ActiveUserTrackerService>());
services.AddHostedService<StartupTasksHostedService>();
}
+5
View File
@@ -75,6 +75,7 @@ public class TaskScheduler : ITaskScheduler
public const string ReadingHistoryAggregationId = TaskSchedulerConstants.ReadingHistoryAggregationId;
public const string AuthKeyExpirationId = TaskSchedulerConstants.AuthKeyExpirationId;
public const string EnsureSideNavId = TaskSchedulerConstants.EnsureSideNavId;
public const string FlushUserActiveTaskId = TaskSchedulerConstants.FlushUserActiveTaskId;
private const int BaseRetryDelay = 60; // 1-minute
@@ -221,6 +222,10 @@ public class TaskScheduler : ITaskScheduler
service => service.AggregateYesterdaysActivity(CancellationToken.None),
"5 0 * * *", RecurringJobOptions); // 12:05 AM daily
RecurringJob.AddOrUpdate<IActiveUserTrackerService>(FlushUserActiveTaskId,
service => service.FlushAsync(CancellationToken.None),
"*/5 * * * *", RecurringJobOptions);
BackgroundJob.Enqueue(() => ScheduleKavitaPlusTasks(CancellationToken.None));
}
+12 -11
View File
@@ -34,7 +34,8 @@ import {
} from "../cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component";
import {EditSeriesModalComponent} from "../cards/_modals/edit-series-modal/edit-series-modal.component";
import {EditVolumeModalComponent} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
import {DownloadService} from "../shared/_services/download.service";
import {DownloadService} from '../shared/_services/download.service';
import {DownloadEntityType} from '../shared/_models/download-queue-item';
import {ReadingProfileService} from "./reading-profile.service";
import {Action} from "../_models/actionables/action";
import {ActionItem} from "../_models/actionables/action-item";
@@ -330,7 +331,7 @@ export class ActionService {
}
case Action.Download:
this.downloadService.download('series', series, series.libraryId, series.id);
this.downloadService.download(DownloadEntityType.Series, series, series.libraryId, series.id);
return of(this.fromAction(action, series, 'none'));
case Action.AddToWantToReadList:
@@ -486,7 +487,7 @@ export class ActionService {
}
case Action.Download:
this.downloadService.download('volume', volume, libraryId, seriesId);
this.downloadService.download(DownloadEntityType.Volume, volume, libraryId, seriesId);
return of(this.fromAction(action, volume, 'none'));
default:
@@ -535,7 +536,7 @@ export class ActionService {
);
case Action.Download:
this.downloadService.download('chapter', chapter, libraryId, seriesId);
this.downloadService.download(DownloadEntityType.Chapter, chapter, libraryId, seriesId);
return of(this.fromAction(action, chapter, 'none'));
case Action.Edit:
@@ -626,7 +627,7 @@ export class ActionService {
);
case Action.Download:
this.downloadService.download('bookmark', [bookmark], 0, 0);
this.downloadService.download(DownloadEntityType.Bookmark, [bookmark], 0, 0);
return of(this.fromAction(action, bookmark, 'none'));
case Action.ViewSeries:
@@ -653,7 +654,7 @@ export class ActionService {
);
case Action.Download:
this.downloadService.download('readingList', readingList, 0, 0);
this.downloadService.download(DownloadEntityType.ReadingList, readingList, 0, 0);
return of(this.fromAction(action, readingList, 'none'));
case Action.Edit:
@@ -707,7 +708,7 @@ export class ActionService {
map(() => this.fromAction(action, {...collection, promoted: false}, 'update'))
);
case Action.Download:
this.downloadService.download('collection', collection, 0, 0);
this.downloadService.download(DownloadEntityType.Collection, collection, 0, 0);
return of(this.fromAction(action, collection, 'none'));
default:
@@ -1033,7 +1034,7 @@ export class ActionService {
}
case Action.Download:
for (const s of series) { this.downloadService.download('series', s, s.libraryId, s.id); }
for (const s of series) { this.downloadService.download(DownloadEntityType.Series, s, s.libraryId, s.id); }
return of(this.fromAction(action, series, 'none'));
default:
@@ -1172,7 +1173,7 @@ export class ActionService {
handleBulkBookmarkAction(action: ActionItem<any>, bookmarks: PageBookmark[], seriesIds: number[]): Observable<ActionResult<PageBookmark[]>> {
switch (action.action) {
case Action.Download:
this.downloadService.download('bookmark', bookmarks, 0, 0);
this.downloadService.download(DownloadEntityType.Bookmark, bookmarks, 0, 0);
return of(this.fromAction(action, bookmarks, 'none'));
case Action.Delete:
@@ -1211,7 +1212,7 @@ export class ActionService {
);
case Action.Download:
for (let c of collections) this.downloadService.download('collection', c, 0, 0);
for (let c of collections) this.downloadService.download(DownloadEntityType.Collection, c, 0, 0);
return of(this.fromAction(action, collections, 'none'));
default:
@@ -1242,7 +1243,7 @@ export class ActionService {
);
case Action.Download:
for (const rl of readingLists) { this.downloadService.download('readingList', rl, 0, 0); }
for (const rl of readingLists) { this.downloadService.download(DownloadEntityType.ReadingList, rl, 0, 0); }
return of(this.fromAction(action, readingLists, 'none'));
default:
@@ -12,9 +12,30 @@ export class EntityTitleService {
private readonly translocoService = inject(TranslocoService);
private readonly utilityService = inject(UtilityService);
computeTitle(
entity: Volume | Chapter,
libraryType: LibraryType | number,
/**
* Formats a Chapter name based on the library it's in
* @param libraryType
* @param plural Pluralize word
* @returns
*/
formatChapterName(libraryType: LibraryType, plural: boolean = false) {
const pluralKeyPart = plural ? '-plural' : '';
switch(libraryType) {
case LibraryType.Book:
case LibraryType.LightNovel:
return this.translocoService.translate('entity-title.book-title' + pluralKeyPart);
case LibraryType.Comic:
case LibraryType.ComicVine:
return this.translocoService.translate('entity-title.issue-title' + pluralKeyPart);
case LibraryType.Images:
case LibraryType.Manga:
return this.translocoService.translate('entity-title.chapter-title' + pluralKeyPart);
}
}
computeTitle(entity: Volume | Chapter, libraryType: LibraryType | number,
options?: {
prioritizeTitleName?: boolean;
fallbackToVolume?: boolean;
@@ -108,9 +129,9 @@ export class EntityTitleService {
if (titleName !== '' && prioritizeTitleName) {
if (isChapter && includeChapter) {
if (number === LooseLeafOrSpecial) {
renderText = this.translocoService.translate('entity-title.chapter') + ' - ';
renderText = this.translocoService.translate('entity-title.chapter-title') + ' - ';
} else {
renderText = this.translocoService.translate('entity-title.chapter', {num: number}) + ' - ';
renderText = this.translocoService.translate('entity-title.chapter-num', {num: number}) + ' - ';
}
}
renderText += titleName;
@@ -123,12 +144,12 @@ export class EntityTitleService {
if (number !== LooseLeafOrSpecial) {
if (isChapter) {
renderText = this.translocoService.translate('entity-title.chapter', {num: number});
renderText = this.translocoService.translate('entity-title.chapter-num', {num: number});
} else {
renderText = volumeTitle;
}
} else if (fallbackToVolume && isChapter && volumeTitle) {
renderText = this.translocoService.translate('entity-title.vol-num', {num: volumeTitle});
renderText = this.translocoService.translate('entity-title.volume-num', {num: volumeTitle});
} else if (fallbackToVolume && isChapter) {
renderText = this.translocoService.translate('entity-title.single-volume');
} else {
@@ -144,7 +165,7 @@ export class EntityTitleService {
if (number !== LooseLeafOrSpecial) {
if (isChapter) {
renderText = this.translocoService.translate('entity-title.chapter', {num: number});
renderText = this.translocoService.translate('entity-title.chapter-num', {num: number});
} else {
renderText = volumeTitle;
}
@@ -15,26 +15,26 @@
<i class="fa {{iconClass()}}" aria-hidden="true"></i>
</button>
<div ngbDropdownMenu attr.aria-labelledby="{{labelId}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: filteredActions() }" />
<ng-container *ngTemplateOutlet="submenu; context: { list: filteredActions(), topLevel: true }" />
</div>
</div>
<ng-template #submenu let-list="list">
<ng-template #submenu let-list="list" let-topLevel="topLevel">
@for(action of list; track action.title) {
<!-- Non Submenu items -->
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
@for(dynamicItem of dList; track dynamicItem.title) {
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
<button ngbDropdownItem (mouseenter)="closeAllSubmenus(topLevel)" (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
}
} @else {
<button ngbDropdownItem (click)="performAction($event, action)">{{t(action.title)}}</button>
<button ngbDropdownItem (mouseenter)="closeAllSubmenus(topLevel)" (click)="performAction($event, action)">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
(click)="openSubmenu(action.title, subMenuHover)"
(mouseenter)="openSubmenu(action.title, subMenuHover)"
(mouseenter)="cancelCloseSubmenus(); openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event)"
class="submenu-wrapper">
@@ -46,7 +46,7 @@
}
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }" />
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children, topLevel: false }" />
</div>
</div>
}
@@ -118,7 +118,8 @@ export class CardActionablesComponent implements OnDestroy {
subMenu.open();
}
closeAllSubmenus() {
closeAllSubmenus(topLevel = false) {
if (!topLevel) return;
// Clear any existing timeout to avoid race conditions
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
@@ -26,7 +26,8 @@ import {ImageService} from "../../_services/image.service";
import {UploadService} from "../../_services/upload.service";
import {MetadataService} from "../../_services/metadata.service";
import {ActionService} from "../../_services/action.service";
import {DownloadService} from "../../shared/_services/download.service";
import {DownloadService} from '../../shared/_services/download.service';
import {DownloadEntityType} from '../../shared/_models/download-queue-item';
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
import {concat, forkJoin, Observable, of, tap} from "rxjs";
@@ -296,7 +297,7 @@ export class EditChapterModalComponent implements OnInit {
});
break;
case Action.Download:
this.downloadService.download('chapter', this.chapter, this.libraryId, this.seriesId);
this.downloadService.download(DownloadEntityType.Chapter, this.chapter, this.libraryId, this.seriesId);
break;
}
}
@@ -18,7 +18,8 @@ import {ImageService} from "../../_services/image.service";
import {UploadService} from "../../_services/upload.service";
import {AccountService} from "../../_services/account.service";
import {ActionService} from "../../_services/action.service";
import {DownloadService} from "../../shared/_services/download.service";
import {DownloadService} from '../../shared/_services/download.service';
import {DownloadEntityType} from '../../shared/_models/download-queue-item';
import {LibraryType} from "../../_models/library/library";
import {PersonRole} from "../../_models/metadata/person";
import {forkJoin} from "rxjs";
@@ -165,7 +166,7 @@ export class EditVolumeModalComponent implements OnInit {
});
break;
case Action.Download:
this.downloadService.download('volume', this.volume, this.libraryId, this.seriesId);
this.downloadService.download(DownloadEntityType.Volume, this.volume, this.libraryId, this.seriesId);
break;
}
}
@@ -19,6 +19,7 @@ import {
import {ServerService} from 'src/app/_services/server.service';
import {Job} from 'src/app/_models/job/job';
import {DownloadService} from 'src/app/shared/_services/download.service';
import {DownloadEntityType} from 'src/app/shared/_models/download-queue-item';
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
import {AsyncPipe, TitleCasePipe} from '@angular/common';
import {translate, TranslocoModule} from "@jsverse/transloco";
@@ -112,7 +113,7 @@ export class ManageTasksSettingsComponent implements OnInit {
{
name: 'download-logs-task',
description: 'download-logs-task-desc',
api: defer(() => of(this.downloadService.download('logs', undefined, 0, 0))),
api: defer(() => of(this.downloadService.download(DownloadEntityType.Logs, undefined, 0, 0))),
successMessage: ''
},
{
@@ -71,6 +71,7 @@ import {KeyBindService} from "../../../_services/key-bind.service";
import {KeyBindTarget} from "../../../_models/preferences/preferences";
import {BreakpointService} from "../../../_services/breakpoint.service";
import {KavitaTitleStrategy} from "../../../_services/kavita-title.strategy";
import {EntityTitleService} from "../../../_services/entity-title.service";
interface HistoryPoint {
@@ -151,6 +152,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly fontService = inject(FontService);
private readonly keyBindService = inject(KeyBindService);
protected readonly breakpointService = inject(BreakpointService);
private readonly entityTitleService = inject(EntityTitleService);
libraryId!: number;
seriesId!: number;
@@ -1147,7 +1149,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
// Load chapter Id onto route but don't reload
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode(), this.readingListMode, this.readingListId);
window.history.replaceState({}, '', newRoute);
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
this.toastr.info(msg, '', {timeOut: 3000});
this.cdRef.markForCheck();
this.init(false);
@@ -1155,7 +1157,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
// This will only happen if no actual chapter can be found
const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter', {entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
this.toastr.warning(msg);
this.isLoading.set(false);
if (direction === 'Prev') {
@@ -85,7 +85,7 @@
</div>
<div class="col-auto ms-2 d-none d-md-block">
<app-download-button [entity]="chapterValue" entityType="chapter" [libraryId]="libraryId()" [seriesId]="seriesId()" />
<app-download-button [entity]="chapterValue" [entityType]="DownloadEntityType.Chapter" [libraryId]="libraryId()" [seriesId]="seriesId()" />
</div>
</div>
@@ -1,3 +1,4 @@
import {DownloadEntityType} from '../shared/_models/download-queue-item';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -129,6 +130,7 @@ enum TabID {
})
export class ChapterDetailComponent implements OnInit {
protected readonly DownloadEntityType = DownloadEntityType;
private readonly document = inject(DOCUMENT);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@@ -158,25 +158,25 @@
</div>
}
<div class="row pt-4 ms-2 me-2 mb-2">
<div class="col">
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === ReaderMode.Webtoon || readerMode === ReaderMode.UpDown" aria-describedby="reading-direction" [title]="t('reading-direction-tooltip') + readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt')">
<div class="col d-flex justify-content-center">
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === ReaderMode.Webtoon || readerMode === ReaderMode.UpDown" aria-describedby="reading-direction" [title]="t('reading-direction-tooltip') + (readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt'))">
<i class="fa fa-angle-double-{{readingDirection === ReadingDirection.LeftToRight ? 'right' : 'left'}}" aria-hidden="true"></i>
<span id="reading-direction" class="visually-hidden">{{readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt')}}</span>
</button>
</div>
<div class="col">
<div class="col d-flex justify-content-center">
<button class="btn btn-icon" [title]="t('reading-mode-tooltip')" (click)="toggleReaderMode();resetMenuCloseTimer();">
<i class="fa {{readerMode | readerModeIcon}}" aria-hidden="true"></i>
<span class="visually-hidden">{{t('reading-mode-tooltip')}}</span>
</button>
</div>
<div class="col">
<div class="col d-flex justify-content-center">
<button class="btn btn-icon" title="{{isFullscreen ? t('collapse') : t('fullscreen')}}" (click)="toggleFullscreen();resetMenuCloseTimer();">
<i class="fa {{isFullscreen | fullscreenIcon}}" aria-hidden="true"></i>
<span class="visually-hidden">{{isFullscreen ? t('collapse') : t('fullscreen')}}</span>
</button>
</div>
<div class="col">
<div class="col d-flex justify-content-center">
<button class="btn btn-icon" [title]="t('settings-tooltip')" (click)="settingsOpen = !settingsOpen;resetMenuCloseTimer();">
<i class="fa fa-sliders-h" aria-hidden="true"></i>
<span class="visually-hidden">{{t('settings-tooltip')}}</span>
@@ -82,6 +82,7 @@ import {KeyBindEvent, KeyBindService} from "../../../_services/key-bind.service"
import {KeyBindTarget} from "../../../_models/preferences/preferences";
import {mediumModal} from "../../../_models/modal/modal-options";
import {ModalService, TypedModalRef} from "../../../_services/modal.service";
import {EntityTitleService} from "../../../_services/entity-title.service";
const PREFETCH_PAGES = 10;
@@ -145,6 +146,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
protected readonly utilityService = inject(UtilityService);
protected readonly mangaReaderService = inject(MangaReaderService);
private readonly keyBindService = inject(KeyBindService);
private readonly entityTitleService = inject(EntityTitleService);
protected readonly KeyDirection = KeyDirection;
protected readonly ReaderMode = ReaderMode;
@@ -1517,12 +1519,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
window.history.replaceState({}, '', newRoute);
this.init(false);
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
this.toastr.info(msg, '', {timeOut: 3000});
} else {
// This will only happen if no actual chapter can be found
const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter',
{entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
{entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
this.toastr.warning(msg);
this.isLoading = false;
if (direction === 'Prev') {
@@ -1,5 +1,5 @@
import {ChangeDetectionStrategy, Component, computed, inject, input, output} from '@angular/core';
import {DownloadQueueItem} from '../../../shared/_models/download-queue-item';
import {DownloadEntityType, DownloadQueueItem} from '../../../shared/_models/download-queue-item';
import {BytesPipe} from '../../../_pipes/bytes.pipe';
import {TranslocoDirective} from "@jsverse/transloco";
import {ImageComponent} from '../../../shared/image/image.component';
@@ -30,10 +30,11 @@ export class DownloadQueueItemComponent {
const item = this.item();
switch (item.entityType) {
case 'volume': return this.imageService.getVolumeCoverImage(item.entityId);
case 'chapter': return this.imageService.getChapterCoverImage(item.entityId);
case 'readinglist-item': return this.imageService.getChapterCoverImage(item.chapterId!);
case DownloadEntityType.Volume: return this.imageService.getVolumeCoverImage(item.entityId);
case DownloadEntityType.Chapter: return this.imageService.getChapterCoverImage(item.entityId);
case DownloadEntityType.ReadingListItem: return this.imageService.getChapterCoverImage(item.chapterId!);
}
return '';
});
readonly statusBadgeColor = computed<TagBadgeColor>(() => {
@@ -55,13 +55,18 @@
</ng-container>
<div class="row mt-2">
@for (chapter of entry.chapters.slice(0, 5); track chapter.chapterId) {
@for (chapter of entry.chapters.slice(0, 5); track chapter.chapterId + '_' + chapter.pagesRead) {
<div class="col-auto d-none d-sm-block chapter-cover"
[routerLink]="['/library', entry.libraryId, 'series', entry.seriesId, 'chapter', chapter.chapterId]">
<app-image [imageUrl]="imageService.getChapterCoverImage(chapter.chapterId)" class="d-block" [ngbTooltip]="chapter.label"
style="width: 2.5rem; height: auto;"/>
</div>
}
@let nonSlicedLength = entry.chapters.length - 5;
@if (nonSlicedLength > 0) {
<i class="fa-solid fa-ellipsis my-auto" tabindex="0" [title]="t('num-more', {num: nonSlicedLength})"></i>
<span class="visually-hidden">{{t('num-more', {num: nonSlicedLength})}}</span>
}
</div>
</div>
</div>
@@ -1,6 +1,7 @@
import {ChangeDetectionStrategy, Component, computed, inject, input} from '@angular/core';
import {AccountService} from "../../../_services/account.service";
import {DownloadService} from "../../../shared/_services/download.service";
import {DownloadService} from '../../../shared/_services/download.service';
import {DownloadEntityType} from '../../../shared/_models/download-queue-item';
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {Chapter} from "../../../_models/chapter";
@@ -25,7 +26,7 @@ export class DownloadButtonComponent {
entity = input.required<Series | Volume | Chapter>();
seriesId = input.required<number>();
libraryId = input.required<number>();
entityType = input<'series' | 'volume' | 'chapter'>('series');
entityType = input<DownloadEntityType>(DownloadEntityType.Series);
isDownloading = computed(() => {
const item = this.downloadService.getItemForEntity(this.entity());
@@ -120,7 +120,7 @@
</div>
<div class="col-auto ms-2 d-none d-md-block btn-actions">
<app-download-button [entity]="seriesValue" entityType="series" [libraryId]="libraryId()" [seriesId]="seriesValue.id" />
<app-download-button [entity]="seriesValue" [entityType]="DownloadEntityType.Series" [libraryId]="libraryId()" [seriesId]="seriesValue.id" />
</div>
</div>
@@ -1,4 +1,5 @@
import {DOCUMENT, Location, NgClass, NgStyle, NgTemplateOutlet} from '@angular/common';
import {DownloadEntityType} from '../../../shared/_models/download-queue-item';
import {
AfterViewInit,
ChangeDetectionStrategy,
@@ -115,6 +116,7 @@ import {patchEntitySignal, patchSignalArray} from "../../../../libs/patch";
import {ModalService} from "../../../_services/modal.service";
import {getResolvedData} from "../../../../libs/route-util";
import {ExternalSeries} from "../../../_models/series-detail/external-series";
import {EntityTitleService} from "../../../_services/entity-title.service";
enum TabID {
@@ -151,6 +153,7 @@ interface StoryLineItem {
})
class SeriesDetailComponent implements OnInit, AfterViewInit {
protected readonly DownloadEntityType = DownloadEntityType;
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly seriesService = inject(SeriesService);
@@ -180,6 +183,7 @@ class SeriesDetailComponent implements OnInit, AfterViewInit {
private readonly location = inject(Location);
private readonly document = inject(DOCUMENT);
protected readonly breakpointService = inject(BreakpointService);
private readonly entityTitleService = inject(EntityTitleService);
readonly scrollingBlock = viewChild<ElementRef<HTMLDivElement>>('scrollingBlock');
@@ -356,7 +360,7 @@ class SeriesDetailComponent implements OnInit, AfterViewInit {
seriesCoverImage = computed(() => this.imageService.getSeriesCoverImage(this.seriesId()));
chapterTabName = computed(() => this.utilityService.formatChapterName(this.libraryType()));
chapterTabName = computed(() => this.entityTitleService.formatChapterName(this.libraryType(), true));
nextExpectedChapter = signal<NextExpectedChapter | null>(null);
loadPageSource = new ReplaySubject<boolean>(1);
loadPage$ = this.loadPageSource.asObservable();
@@ -1,13 +1,29 @@
import {Volume} from "../../_models/volume";
import {Chapter} from "../../_models/chapter";
import {ReadingListItem} from "../../_models/reading-list";
import {Volume} from '../../_models/volume';
import {Chapter} from '../../_models/chapter';
import {ReadingListItem} from '../../_models/reading-list';
/** All valid entity types for downloading */
export enum DownloadEntityType {
Series = 'series',
Volume = 'volume',
Chapter = 'chapter',
ReadingListItem = 'readingListItem',
ReadingList = 'readingList',
Collection = 'collection',
Bookmark = 'bookmark',
Logs = 'logs',
}
/** Distilled types that actually enter the queue (volumes, chapters, reading list items) */
export type DistilledDownloadEntityType =
DownloadEntityType.Volume | DownloadEntityType.Chapter | DownloadEntityType.ReadingListItem;
export type DownloadQueueStatus = 'queued' | 'preparing' | 'downloading' | 'completed' | 'failed' | 'cancelled';
export interface DownloadQueueItem {
id: number;
/** Atomic unit of download, series/reading-list/collection always decompose to these */
entityType: 'volume' | 'chapter' | 'readinglist-item';
entityType: DistilledDownloadEntityType;
entityId: number;
libraryId: number;
seriesId: number;
@@ -27,7 +43,7 @@ export interface DownloadQueueItem {
queuedAt: string | number;
/** Present only for in-memory items; stripped before IndexedDB persistence and absent on restored items. */
entity?: Volume | Chapter | ReadingListItem;
/** For readinglist-item: the chapter ID to use for the download endpoint. Persisted to IDB. */
/** For ReadingListItem: the chapter ID to use for the download endpoint. Persisted to IDB. */
chapterId?: number;
/** Predicted backend filename used to match SignalR progress events */
downloadName: string;
@@ -20,7 +20,12 @@ import {UtcToLocalDatePipe} from '../../_pipes/utc-to-locale-date.pipe';
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {NotificationProgressEvent} from "../../_models/events/notification-progress-event";
import {SeriesService} from "../../_services/series.service";
import {DownloadQueueItem, DownloadQueueStatus} from '../_models/download-queue-item';
import {
DistilledDownloadEntityType,
DownloadEntityType,
DownloadQueueItem,
DownloadQueueStatus
} from '../_models/download-queue-item';
import {DownloadStorageService} from './download-storage.service';
import {normalizeTimestamp} from "../../../libs/download-timestamp";
import {ReadingList, ReadingListItem} from "../../_models/reading-list";
@@ -34,13 +39,7 @@ export const DEBOUNCE_TIME = 100;
const bytesPipe = new BytesPipe();
/**
* Valid entity types for downloading
*/
export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' | 'logs' | 'readingList' | 'readingListItem' | 'collection';
/**
* Valid entities for downloading. Undefined exclusively for logs.
*/
/** Valid entities for downloading. Undefined exclusively for logs */
export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | ReadingList | ReadingListItem | UserCollection | undefined;
@Injectable({
@@ -235,12 +234,12 @@ export class DownloadService {
*/
downloadSubtitle(downloadEntityType: DownloadEntityType | undefined, downloadEntity: DownloadEntity | undefined) {
switch (downloadEntityType) {
case 'series': return (downloadEntity as Series).name;
case 'volume': return (downloadEntity as Volume).minNumber + '';
case 'chapter': return (downloadEntity as Chapter).minNumber + '';
case 'bookmark': return '';
case 'logs': return '';
case 'readingListItem': return (downloadEntity as ReadingListItem).title;
case DownloadEntityType.Series: return (downloadEntity as Series).name;
case DownloadEntityType.Volume: return (downloadEntity as Volume).minNumber + '';
case DownloadEntityType.Chapter: return (downloadEntity as Chapter).minNumber + '';
case DownloadEntityType.Bookmark: return '';
case DownloadEntityType.Logs: return '';
case DownloadEntityType.ReadingListItem: return (downloadEntity as ReadingListItem).title;
}
return '';
}
@@ -253,25 +252,25 @@ export class DownloadService {
*/
download(entityType: DownloadEntityType, entity: DownloadEntity, libraryId: number, seriesId: number) {
switch (entityType) {
case 'series':
case DownloadEntityType.Series:
this.downloadSeries(entity as Series);
break;
case 'volume':
case DownloadEntityType.Volume:
this.downloadVolume(entity as Volume, libraryId, seriesId);
break;
case 'chapter':
this.enqueueSingle(entity as Chapter, 'chapter', '', libraryId, seriesId);
case DownloadEntityType.Chapter:
this.enqueueSingle(entity as Chapter, DownloadEntityType.Chapter, '', libraryId, seriesId);
break;
case 'bookmark':
case DownloadEntityType.Bookmark:
this.downloadBookmarksBlob(entity as PageBookmark[]);
break;
case 'logs':
case DownloadEntityType.Logs:
this.downloadLogsBlob();
break;
case 'readingList':
case DownloadEntityType.ReadingList:
this.downloadReadingList(entity as ReadingList);
break;
case 'collection':
case DownloadEntityType.Collection:
this.downloadCollection(entity as UserCollection);
break;
}
@@ -282,9 +281,9 @@ export class DownloadService {
* Downloads multiple volumes and chapters in bulk, using only 2 HTTP size calls total.
*/
downloadBulk(volumes: Volume[], chapters: Chapter[], libraryId = 0, seriesId = 0) {
const items: Array<{ entity: Volume | Chapter; entityType: 'volume' | 'chapter' }> = [
...volumes.map(v => ({ entity: v as Volume, entityType: 'volume' as const })),
...chapters.map(c => ({ entity: c as Chapter, entityType: 'chapter' as const })),
const items: Array<{ entity: Volume | Chapter; entityType: DownloadEntityType.Volume | DownloadEntityType.Chapter }> = [
...volumes.map(v => ({ entity: v as Volume, entityType: DownloadEntityType.Volume as const })),
...chapters.map(c => ({ entity: c as Chapter, entityType: DownloadEntityType.Chapter as const })),
];
if (items.length === 0) return;
this.enqueueItems(items, '', libraryId, seriesId);
@@ -459,7 +458,7 @@ export class DownloadService {
}
// Volume/Chapter: O(1) Map lookup for active
const entityType = this.utilityService.isVolume(entity) ? 'volume' : 'chapter';
const entityType = this.utilityService.isVolume(entity) ? DownloadEntityType.Volume : DownloadEntityType.Chapter;
const key = this._indexKey(entityType, (entity as Volume | Chapter).id);
const active = this._activeIndex.get(key);
if (active && ['queued', 'preparing', 'downloading'].includes(active.status)) return active;
@@ -522,30 +521,30 @@ export class DownloadService {
URL.revokeObjectURL(url);
}
private getEntityDownloadSize(entityType: 'series' | 'volume' | 'chapter' | 'readinglist', id: number) {
private getEntityDownloadSize(entityType: DownloadEntityType.Series | DownloadEntityType.Volume | DownloadEntityType.Chapter, id: number) {
return this.httpClient.get<number>(this.baseUrl + `download/${entityType}-size?${entityType}Id=${id}`);
}
private getBulkEntityDownloadSize(entityType: 'series' | 'volume' | 'chapter' | 'readinglist', ids: number[]) {
private getBulkEntityDownloadSize(entityType: DownloadEntityType.Series | DownloadEntityType.Volume | DownloadEntityType.Chapter, ids: number[]) {
const data = {} as any;
data[entityType + 'Ids'] = ids;
return this.httpClient.post<Record<number, number>>(this.baseUrl + `download/bulk-${entityType}-size`, data);
}
private downloadSeriesSize(seriesId: number) {
return this.getEntityDownloadSize('series', seriesId);
return this.getEntityDownloadSize(DownloadEntityType.Series, seriesId);
}
private downloadBulkVolumeSizes(volumeIds: number[]) {
return this.getBulkEntityDownloadSize('volume', volumeIds);
return this.getBulkEntityDownloadSize(DownloadEntityType.Volume, volumeIds);
}
private downloadBulkChapterSizes(chapterIds: number[]) {
return this.getBulkEntityDownloadSize('chapter', chapterIds);
return this.getBulkEntityDownloadSize(DownloadEntityType.Chapter, chapterIds);
}
private downloadVolumeSize(volumeId: number) {
return this.getEntityDownloadSize('volume', volumeId);
return this.getEntityDownloadSize(DownloadEntityType.Volume, volumeId);
}
@@ -554,18 +553,18 @@ export class DownloadService {
// Volumes can be either a bunch of chapters or just 1
if (volume.chapters.length === 1) {
this.enqueueSingle(volume, 'volume', '', libraryId, seriesId);
this.enqueueSingle(volume, DownloadEntityType.Volume, '', libraryId, seriesId);
return;
}
this.debugLog(`downloadVolume() decomposed into ${volume.chapters.length} items`);
const items = volume.chapters.map(c => ({ entity: c as Chapter, entityType: 'chapter' as const }));
const items = volume.chapters.map(c => ({ entity: c as Chapter, entityType: DownloadEntityType.Chapter as const }));
const userPrefs = this.accountService.userPreferences();
if (userPrefs?.promptForDownloadSize && items.length > 0) {
// Single size call for the whole series, single confirm dialog
this.downloadVolumeSize(volume.id).pipe(
switchMap(async size => this.confirmSize(size, 'volume')),
switchMap(async size => this.confirmSize(size, DownloadEntityType.Volume)),
filter(confirmed => confirmed),
takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.enqueueItems(items, '', libraryId, seriesId));
@@ -589,9 +588,9 @@ export class DownloadService {
if (userPrefs?.promptForDownloadSize && collectionSeries.result.length > 0) {
const seriesIds = collectionSeries.result.map(s => s.id);
this.getBulkEntityDownloadSize('series', seriesIds).pipe(
this.getBulkEntityDownloadSize(DownloadEntityType.Series, seriesIds).pipe(
map(r => Object.values(r).reduce((acc, curr) => acc + curr, 0)),
switchMap(async size => this.confirmSize(size, 'series')),
switchMap(async size => this.confirmSize(size, DownloadEntityType.Series)),
filter(confirmed => confirmed),
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
@@ -616,7 +615,7 @@ export class DownloadService {
this.readingListService.getListItems(readingList.id);
items$.subscribe((items: ReadingListItem[]) => {
const rliItems = items.map(item => ({ entity: item as ReadingListItem, entityType: 'readinglist-item' as const }));
const rliItems = items.map(item => ({ entity: item as ReadingListItem, entityType: DownloadEntityType.ReadingListItem as const }));
this.enqueueItems(rliItems, readingList.title, 0, 0, readingList.id);
});
}
@@ -626,10 +625,10 @@ export class DownloadService {
this.seriesService.getSeriesDetail(series.id).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(detail => {
const items: Array<{ entity: Volume | Chapter; entityType: 'volume' | 'chapter' }> = [
...detail.volumes.map(v => ({ entity: v as Volume, entityType: 'volume' as const })),
...detail.chapters.map(c => ({ entity: c as Chapter, entityType: 'chapter' as const })),
...detail.specials.map(c => ({ entity: c as Chapter, entityType: 'chapter' as const })),
const items: Array<{ entity: Volume | Chapter; entityType: DownloadEntityType.Volume | DownloadEntityType.Chapter }> = [
...detail.volumes.map(v => ({ entity: v as Volume, entityType: DownloadEntityType.Volume as const })),
...detail.chapters.map(c => ({ entity: c as Chapter, entityType: DownloadEntityType.Chapter as const })),
...detail.specials.map(c => ({ entity: c as Chapter, entityType: DownloadEntityType.Chapter as const })),
];
this.debugLog(`downloadSeries() decomposed into ${items.length} items (${detail.volumes.length} vols, ${detail.chapters.length + detail.specials.length} chapters)`);
@@ -637,7 +636,7 @@ export class DownloadService {
if (!skipSizePrompt && userPrefs?.promptForDownloadSize && items.length > 0) {
// Single size call for the whole series, single confirm dialog
this.downloadSeriesSize(series.id).pipe(
switchMap(async size => this.confirmSize(size, 'series')),
switchMap(async size => this.confirmSize(size, DownloadEntityType.Series)),
filter(confirmed => confirmed),
takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.enqueueItems(items, series.name, series.libraryId, series.id, 0, collectionId));
@@ -647,22 +646,22 @@ export class DownloadService {
});
}
private enqueueItems(items: Array<{ entity: Volume | Chapter | ReadingListItem; entityType: 'volume' | 'chapter' | 'readinglist-item' }>, seriesName: string, libraryId: number, seriesId = 0, readingListId = 0, collectionId = 0) {
private enqueueItems(items: Array<{ entity: Volume | Chapter | ReadingListItem; entityType: DistilledDownloadEntityType }>, seriesName: string, libraryId: number, seriesId = 0, readingListId = 0, collectionId = 0) {
this.debugLog(`enqueueItems() adding ${items.length} items for series "${seriesName}"`);
const volumeItems = items.filter(i => i.entityType === 'volume');
const chapterItems = items.filter(i => i.entityType === 'chapter');
const rliItems = items.filter(i => i.entityType === 'readinglist-item');
const volumeItems = items.filter(i => i.entityType === DownloadEntityType.Volume);
const chapterItems = items.filter(i => i.entityType === DownloadEntityType.Chapter);
const rliItems = items.filter(i => i.entityType === DownloadEntityType.ReadingListItem);
const volSizes$ = volumeItems.length > 0
? this.getBulkEntityDownloadSize('volume', volumeItems.map(i => i.entity.id))
? this.getBulkEntityDownloadSize(DownloadEntityType.Volume, volumeItems.map(i => i.entity.id))
: of({} as Record<number, number>);
const chSizes$ = chapterItems.length > 0
? this.getBulkEntityDownloadSize('chapter', chapterItems.map(i => i.entity.id))
? this.getBulkEntityDownloadSize(DownloadEntityType.Chapter, chapterItems.map(i => i.entity.id))
: of({} as Record<number, number>);
// ReadingListItems download via the chapter endpoint, so fetch chapter sizes using chapterId
const rliSizes$ = rliItems.length > 0
? this.getBulkEntityDownloadSize('chapter', rliItems.map(i => (i.entity as ReadingListItem).chapterId))
? this.getBulkEntityDownloadSize(DownloadEntityType.Chapter, rliItems.map(i => (i.entity as ReadingListItem).chapterId))
: of({} as Record<number, number>);
forkJoin([volSizes$, chSizes$, rliSizes$]).pipe(
@@ -672,13 +671,13 @@ export class DownloadService {
let size: number;
switch (item.entityType) {
case "volume":
case DownloadEntityType.Volume:
size = volMap[item.entity.id] ?? 0;
break;
case "chapter":
case DownloadEntityType.Chapter:
size = chMap[item.entity.id] ?? 0;
break;
case "readinglist-item":
case DownloadEntityType.ReadingListItem:
size = rlMap[(item.entity as ReadingListItem).chapterId] ?? 0;
break;
}
@@ -689,9 +688,9 @@ export class DownloadService {
});
}
private enqueueSingle(entity: Volume | Chapter, entityType: 'volume' | 'chapter', seriesName: string, libraryId: number, seriesId = 0, readingListId = 0, collectionId = 0) {
private enqueueSingle(entity: Volume | Chapter, entityType: DownloadEntityType.Volume | DownloadEntityType.Chapter, seriesName: string, libraryId: number, seriesId = 0, readingListId = 0, collectionId = 0) {
const user = this.accountService.currentUser();
const sizeCall$ = entityType === 'volume'
const sizeCall$ = entityType === DownloadEntityType.Volume
? this.downloadBulkVolumeSizes([entity.id]).pipe(map(m => m[entity.id] ?? 0))
: this.downloadBulkChapterSizes([entity.id]).pipe(map(m => m[entity.id] ?? 0));
@@ -734,7 +733,7 @@ export class DownloadService {
}
}
private async addToQueue(entity: Volume | Chapter | ReadingListItem, entityType: 'volume' | 'chapter' | 'readinglist-item', seriesName: string, libraryId: number, estimatedSize = 0, seriesId = 0, readingListId = 0, collectionId = 0, skipRedownloadPrompt = false) {
private async addToQueue(entity: Volume | Chapter | ReadingListItem, entityType: DistilledDownloadEntityType, seriesName: string, libraryId: number, estimatedSize = 0, seriesId = 0, readingListId = 0, collectionId = 0, skipRedownloadPrompt = false) {
seriesName = await this.resolveSeriesName(seriesName, seriesId);
const entityId = entity.id;
const key = this._indexKey(entityType, entityId);
@@ -777,11 +776,11 @@ export class DownloadService {
let downloadName: string;
let chapterId: number | undefined;
if (entityType === 'volume') {
if (entityType === DownloadEntityType.Volume) {
const vol = entity as Volume;
subLabel = vol.minNumber + '';
downloadName = seriesName ? `${seriesName} - Volume ${vol.name}` : `Volume ${vol.name}`;
} else if (entityType === 'readinglist-item') {
} else if (entityType === DownloadEntityType.ReadingListItem) {
const rli = entity as ReadingListItem;
subLabel = rli.title;
downloadName = seriesName ? `${seriesName} - ${rli.title}` : rli.title;
@@ -857,10 +856,10 @@ export class DownloadService {
return;
}
// readinglistitem downloads via the chapter endpoint using chapterId
const endpoint = item.entityType === 'readinglist-item' ? 'chapter' : item.entityType;
const idKey = endpoint === 'volume' ? 'volumeId' : 'chapterId';
const idValue = item.entityType === 'readinglist-item' ? item.chapterId! : item.entityId;
// readingListItem downloads via the chapter endpoint using chapterId
const endpoint = item.entityType === DownloadEntityType.ReadingListItem ? DownloadEntityType.Chapter : item.entityType;
const idKey = endpoint === DownloadEntityType.Volume ? 'volumeId' : 'chapterId';
const idValue = item.entityType === DownloadEntityType.ReadingListItem ? item.chapterId! : item.entityId;
const url = `${this.baseUrl}download/${endpoint}` +
`?${idKey}=${idValue}` +
`&correlationId=${item.id}` +
@@ -1,12 +1,10 @@
import {HttpParams} from '@angular/common/http';
import {inject, Injectable} from '@angular/core';
import {Chapter} from 'src/app/_models/chapter';
import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format';
import {PaginatedResult} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series';
import {Volume} from 'src/app/_models/volume';
import {translate} from "@jsverse/transloco";
import {DOCUMENT} from "@angular/common";
import {ActionItem} from "../../_models/actionables/action-item";
@@ -49,34 +47,6 @@ export class UtilityService {
return this.mangaFormatKeys.filter(item => MangaFormat[format] === item)[0];
}
/**
* Formats a Chapter name based on the library it's in
* @param libraryType
* @param includeHash For comics only, includes a # which is used for numbering on cards
* @param includeSpace Add a space at the end of the string. if includeHash and includeSpace are true, only hash will be at the end.
* @param plural Pluralize word
* @returns
*/
formatChapterName(libraryType: LibraryType, includeHash: boolean = false, includeSpace: boolean = false, plural: boolean = false) {
const extra = plural ? 's' : '';
switch(libraryType) {
case LibraryType.Book:
case LibraryType.LightNovel:
return translate('common.book-num' + extra) + (includeSpace ? ' ' : '');
case LibraryType.Comic:
case LibraryType.ComicVine:
if (includeHash) {
return translate('common.issue-hash-num');
}
return translate('common.issue-num' + extra) + (includeSpace ? ' ' : '');
case LibraryType.Images:
case LibraryType.Manga:
return translate('common.chapter-num' + extra) + (includeSpace ? ' ' : '');
}
}
filter(input: string, filter: string): boolean {
if (input === null || filter === null || input === undefined || filter === undefined) return false;
const reg = /[_\.\-]/gi;
@@ -84,7 +84,7 @@
</div>
<div class="col-auto ms-2 d-none d-md-block">
<app-download-button [entity]="volume()" entityType="volume" [libraryId]="libraryId()" [seriesId]="seriesId()" />
<app-download-button [entity]="volume()" [entityType]="DownloadEntityType.Volume" [libraryId]="libraryId()" [seriesId]="seriesId()" />
</div>
</div>
@@ -1,3 +1,4 @@
import {DownloadEntityType} from '../shared/_models/download-queue-item';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -87,6 +88,7 @@ import {ModalService} from "../_services/modal.service";
import {getResolvedData, getWritableResolvedData} from "../../libs/route-util";
import {ModalResult} from "../_models/modal/modal-result";
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
import {EntityTitleService} from "../_services/entity-title.service";
enum TabID {
Chapters = 'chapters-tab',
@@ -168,6 +170,7 @@ interface VolumeCast extends IHasCast {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class VolumeDetailComponent implements OnInit {
protected readonly DownloadEntityType = DownloadEntityType;
private readonly document = inject(DOCUMENT);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@@ -189,6 +192,7 @@ export class VolumeDetailComponent implements OnInit {
private readonly chapterService = inject(ChapterService);
private readonly annotationService = inject(AnnotationService);
protected readonly breakpointService = inject(BreakpointService);
private readonly entityTitleService = inject(EntityTitleService);
readonly scrollingBlock = viewChild<ElementRef<HTMLDivElement>>('scrollingBlock');
@@ -324,7 +328,7 @@ export class VolumeDetailComponent implements OnInit {
return Math.max(...chapters.map(c => c.ageRating));
});
chapterTabName = computed(() => this.utilityService.formatChapterName(this.libraryType(), false, false, true));
chapterTabName = computed(() => this.entityTitleService.formatChapterName(this.libraryType(), true));
reviewCount = computed(() => this.userReviews().length + this.plusReviews().length);
+16 -13
View File
@@ -173,8 +173,8 @@
"delete-selected-label": "Delete selected",
"no-data": "{{common.no-data}}",
"volume-and-chapter-num": "Volume {{v}} Chapter {{n}}",
"volume-num": "Volume {{num}}",
"chapter-num": "Chapter {{num}}",
"volume-num": "{{entity-title.volume-num}}",
"chapter-num": "{{entity-title.chapter-num}}",
"rating": "Rating {{r}}",
"not-applicable": "Not Applicable",
"processed": "Processed",
@@ -1594,11 +1594,18 @@
"entity-title": {
"special": "Special",
"issue-num": "{{common.issue-hash-num}}",
"chapter": "{{common.chapter-num}}",
"book-num": "{{common.book-num-shorthand}}",
"vol-num": "{{user-scrobble-history.volume-num}}",
"single-volume": "Single Volume"
"issue-num": "Issue #{{num}}",
"chapter-num": "Chapter {{num}}",
"book-num": "Book {{num}}",
"volume-num": "Volume {{num}}",
"single-volume": "Single Volume",
"book-title": "Book",
"book-title-plural": "Books",
"issue-title": "Issue",
"issue-title-plural": "Issues",
"chapter-title": "Chapter",
"chapter-title-plural": "Chapters"
},
"external-series-card": {
@@ -2915,7 +2922,8 @@
"today": "Today",
"yesterday": "Yesterday",
"pagination-label": "Activity pagination",
"page-info": "Page {{current}} of {{total}} ({{items}} total records)"
"page-info": "Page {{current}} of {{total}} ({{items}} total records)",
"num-more": "{{num}} more"
},
"user-stats-info-cards": {
@@ -3798,11 +3806,6 @@
"book-num-shorthand": "Book {{num}}",
"issue-num-shorthand": "#{{num}}",
"volume-num-shorthand": "Vol {{num}}",
"book-nums": "Books",
"issue-nums": "Issues",
"chapter-nums": "Chapters",
"volume-nums": "Volumes",
"roles": "Roles",
"libraries": "Libraries"
}