From e57ac309eb3d7f88dc0a75bbfd4d187bd27b7e91 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Thu, 12 Mar 2026 13:06:43 -0500 Subject: [PATCH] More Bugs (#4516) --- .github/workflows/openapi-gen.yml | 4 +- Kavita.API/Repositories/IUserRepository.cs | 1 - .../Services/IActiveUserTrackerService.cs | 10 ++ Kavita.Common/Helpers/RateLimiter.cs | 2 - .../Repositories/UserRepository.cs | 16 +-- .../Constants/TaskSchedulerConstants.cs | 1 + .../Controllers/ReadingListController.cs | 1 - .../UpdateUserAsActiveMiddleware.cs | 8 +- Kavita.Services/ActiveUserTrackerService.cs | 77 +++++++++++ .../ApplicationServiceExtensions.cs | 3 + Kavita.Services/TaskScheduler.cs | 5 + UI/Web/src/app/_services/action.service.ts | 23 ++-- .../src/app/_services/entity-title.service.ts | 37 ++++-- .../card-actionables.component.html | 12 +- .../card-actionables.component.ts | 3 +- .../edit-chapter-modal.component.ts | 5 +- .../edit-volume-modal.component.ts | 5 +- .../manage-tasks-settings.component.ts | 3 +- .../book-reader/book-reader.component.ts | 6 +- .../chapter-detail.component.html | 2 +- .../chapter-detail.component.ts | 2 + .../manga-reader/manga-reader.component.html | 10 +- .../manga-reader/manga-reader.component.ts | 6 +- .../download-queue-item.component.ts | 9 +- .../profile-activity.component.html | 7 +- .../download-button.component.ts | 5 +- .../series-detail.component.html | 2 +- .../series-detail/series-detail.component.ts | 6 +- .../app/shared/_models/download-queue-item.ts | 26 +++- .../app/shared/_services/download.service.ts | 123 +++++++++--------- .../app/shared/_services/utility.service.ts | 30 ----- .../volume-detail.component.html | 2 +- .../volume-detail/volume-detail.component.ts | 6 +- UI/Web/src/assets/langs/en.json | 29 +++-- 34 files changed, 303 insertions(+), 184 deletions(-) create mode 100644 Kavita.API/Services/IActiveUserTrackerService.cs create mode 100644 Kavita.Services/ActiveUserTrackerService.cs diff --git a/.github/workflows/openapi-gen.yml b/.github/workflows/openapi-gen.yml index 8272ae392..0ab282344 100644 --- a/.github/workflows/openapi-gen.yml +++ b/.github/workflows/openapi-gen.yml @@ -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 '> $GITHUB_OUTPUT echo "Found Swashbuckle.AspNetCore version: $VERSION" diff --git a/Kavita.API/Repositories/IUserRepository.cs b/Kavita.API/Repositories/IUserRepository.cs index d5940aebb..97d8ce668 100644 --- a/Kavita.API/Repositories/IUserRepository.cs +++ b/Kavita.API/Repositories/IUserRepository.cs @@ -78,7 +78,6 @@ public interface IUserRepository Task> GetUserTokenInfo(CancellationToken ct = default); Task GetUserByDeviceEmail(string deviceEmail, CancellationToken ct = default); Task GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None, CancellationToken ct = default); - Task UpdateUserAsActive(int userId, CancellationToken ct = default); #endregion #region Ratings & Reviews diff --git a/Kavita.API/Services/IActiveUserTrackerService.cs b/Kavita.API/Services/IActiveUserTrackerService.cs new file mode 100644 index 000000000..c73cf1a11 --- /dev/null +++ b/Kavita.API/Services/IActiveUserTrackerService.cs @@ -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); +} diff --git a/Kavita.Common/Helpers/RateLimiter.cs b/Kavita.Common/Helpers/RateLimiter.cs index 735a96a94..e31ca8d3e 100644 --- a/Kavita.Common/Helpers/RateLimiter.cs +++ b/Kavita.Common/Helpers/RateLimiter.cs @@ -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}"); } } } diff --git a/Kavita.Database/Repositories/UserRepository.cs b/Kavita.Database/Repositories/UserRepository.cs index 26d96011f..97f677b5e 100644 --- a/Kavita.Database/Repositories/UserRepository.cs +++ b/Kavita.Database/Repositories/UserRepository.cs @@ -607,17 +607,8 @@ public class UserRepository(DataContext context, UserManager userManage .ToListAsync(ct); } - public async Task UpdateUserAsActive(int userId, CancellationToken ct = default) - { - await context.Set() - .Where(u => u.Id == userId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(u => u.LastActiveUtc, DateTime.UtcNow) - .SetProperty(u => u.LastActive, DateTime.Now), ct); - } - /// - /// Retrieve all reviews (series and chapter) for a given user, respecting profile privacy settings and age restrictions. + /// Retrieve all reviews (series and chapter) for a given user, respecting profile privacy settings and age restrictions. /// /// UserId of source user /// Viewer UserId @@ -625,7 +616,8 @@ public class UserRepository(DataContext context, UserManager userManage /// Rating, only applies to series/chapters rated. Will show everything greater or equal to /// /// - public async Task> GetAllReviewsForUser(int userId, int requestingUserId, string? query = null, float? ratingFilter = null, CancellationToken ct = default) + public async Task> 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 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 diff --git a/Kavita.Models/Constants/TaskSchedulerConstants.cs b/Kavita.Models/Constants/TaskSchedulerConstants.cs index af26b84e2..2762489b5 100644 --- a/Kavita.Models/Constants/TaskSchedulerConstants.cs +++ b/Kavita.Models/Constants/TaskSchedulerConstants.cs @@ -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"; } diff --git a/Kavita.Server/Controllers/ReadingListController.cs b/Kavita.Server/Controllers/ReadingListController.cs index e3b2134eb..4d4de1159 100644 --- a/Kavita.Server/Controllers/ReadingListController.cs +++ b/Kavita.Server/Controllers/ReadingListController.cs @@ -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")); diff --git a/Kavita.Server/Middleware/UpdateUserAsActiveMiddleware.cs b/Kavita.Server/Middleware/UpdateUserAsActiveMiddleware.cs index f278c7edf..eb6b6b0de 100644 --- a/Kavita.Server/Middleware/UpdateUserAsActiveMiddleware.cs +++ b/Kavita.Server/Middleware/UpdateUserAsActiveMiddleware.cs @@ -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; /// 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) diff --git a/Kavita.Services/ActiveUserTrackerService.cs b/Kavita.Services/ActiveUserTrackerService.cs new file mode 100644 index 000000000..896841608 --- /dev/null +++ b/Kavita.Services/ActiveUserTrackerService.cs @@ -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; + +/// +/// Responsible to track userId via . Flushes the queue every 5 mins. +/// +public sealed class ActiveUserTrackerService( IServiceScopeFactory serviceScopeFactory, ILogger logger) + : IActiveUserTrackerService, IHostedService +{ + private readonly RateLimiter _rateLimiter = new(1, TimeSpan.FromMinutes(5), refillBetween: false); + private readonly ConcurrentDictionary _pendingUpdates = new(); + + /// + /// Enqueue that the userId was seen + /// + /// + public void RecordActive(int userId) + { + if (_rateLimiter.TryAcquire(userId.ToString())) + { + _pendingUpdates[userId] = DateTime.UtcNow; + } + } + + /// + /// Flush the queue of userId's to the DB. + /// + /// Expected to be run by Hangfire, not invoked manually + /// + 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(); + + 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); + } +} diff --git a/Kavita.Services/Extensions/ApplicationServiceExtensions.cs b/Kavita.Services/Extensions/ApplicationServiceExtensions.cs index 38f7c4001..9d879e07c 100644 --- a/Kavita.Services/Extensions/ApplicationServiceExtensions.cs +++ b/Kavita.Services/Extensions/ApplicationServiceExtensions.cs @@ -93,6 +93,9 @@ public static class ApplicationServiceExtensions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // This is required for the below lines. It allows IHostedService.StopAsync() to be called on shutdown + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(); } diff --git a/Kavita.Services/TaskScheduler.cs b/Kavita.Services/TaskScheduler.cs index f24bbba75..981d980b6 100644 --- a/Kavita.Services/TaskScheduler.cs +++ b/Kavita.Services/TaskScheduler.cs @@ -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(FlushUserActiveTaskId, + service => service.FlushAsync(CancellationToken.None), + "*/5 * * * *", RecurringJobOptions); + BackgroundJob.Enqueue(() => ScheduleKavitaPlusTasks(CancellationToken.None)); } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 81ecc5742..30ee36594 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -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, bookmarks: PageBookmark[], seriesIds: number[]): Observable> { 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: diff --git a/UI/Web/src/app/_services/entity-title.service.ts b/UI/Web/src/app/_services/entity-title.service.ts index 40b509997..35bc0d87c 100644 --- a/UI/Web/src/app/_services/entity-title.service.ts +++ b/UI/Web/src/app/_services/entity-title.service.ts @@ -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; } diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html index 1db8558d1..3a7434915 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html @@ -15,26 +15,26 @@
- +
- + @for(action of list; track action.title) { @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) { - + } } @else { - + } } @else { @if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) { } diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts index 6dd98ec48..839cb96fe 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts @@ -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); diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts index c3dce1746..c25617c60 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -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; } } diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts index 395a24ea1..82aca8615 100644 --- a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts @@ -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; } } diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index e2094e10f..cc3fb71ed 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -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: '' }, { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 5ebd16d40..73705e422 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -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') { diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.html b/UI/Web/src/app/chapter-detail/chapter-detail.component.html index 21ac99878..579de9188 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -85,7 +85,7 @@
- +
diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index 2f0f0746c..1bd3fc281 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -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); diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html index 667b772ed..eae38a5d3 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html @@ -158,25 +158,25 @@ }
-
-
-
+
-
+
-
+
diff --git a/UI/Web/src/app/series-detail/_components/download-button/download-button.component.ts b/UI/Web/src/app/series-detail/_components/download-button/download-button.component.ts index 38907e105..9ee04096d 100644 --- a/UI/Web/src/app/series-detail/_components/download-button/download-button.component.ts +++ b/UI/Web/src/app/series-detail/_components/download-button/download-button.component.ts @@ -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(); seriesId = input.required(); libraryId = input.required(); - entityType = input<'series' | 'volume' | 'chapter'>('series'); + entityType = input(DownloadEntityType.Series); isDownloading = computed(() => { const item = this.downloadService.getItemForEntity(this.entity()); diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index e640e1297..de1d74de7 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -120,7 +120,7 @@
- +
diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 1d20843c4..b76fa7c02 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -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>('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(null); loadPageSource = new ReplaySubject(1); loadPage$ = this.loadPageSource.asObservable(); diff --git a/UI/Web/src/app/shared/_models/download-queue-item.ts b/UI/Web/src/app/shared/_models/download-queue-item.ts index 57c35d24a..cb5966923 100644 --- a/UI/Web/src/app/shared/_models/download-queue-item.ts +++ b/UI/Web/src/app/shared/_models/download-queue-item.ts @@ -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; diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index b40814e64..fbaee4618 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -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(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>(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); 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); // 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); 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}` + diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 4e3e6c5a7..fa4d8b706 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -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; diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index 2aaffb478..237af234a 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -84,7 +84,7 @@
- +
diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index 14ad47340..ac543e984 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -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>('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); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index bd7a32765..76b0a09e4 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -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" }