Files
bookorbit/server/src/modules/book/book.service.ts
T
neon d66f853cad feat(settings): add read-aloud filename token
Expose media-overlay-aware naming across file creation and delivery paths while preserving existing client contracts. Keep settings router mocks compatible with the application router exports.

Closes #1590
2026-09-29 01:29:25 +00:00

3710 lines
161 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { mkdtemp, rm, stat, rename } from 'fs/promises';
import { inArray, type SQL } from 'drizzle-orm';
import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../common/constants/pagination.constants';
import { compareAudioTracks, coverFetchInputs, resolveIsAudiobook } from '../../common/utils/book-media.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { selectPrimaryFile } from '../../common/utils/primary-file-selection.utils';
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
import { naturalCompare } from '../../common/utils/natural-sort.utils';
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { isDateKey, resolveTimeZone, toDateKeyInTimeZone, toTimeZoneStartOfDay } from '../../common/utils/timezone.utils';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractAudioMetadata } from '../metadata/extractors/audio.extractor';
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
import { parseFb2File } from '../metadata/lib/fb2-parser';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
import { basename, dirname, extname, join, relative } from 'path';
import { tmpdir } from 'os';
import {
BOOK_METADATA_LOCK_FIELDS,
DEFAULT_DOWNLOAD_PATTERN,
COVER_MEDIA,
DEFAULT_FORMAT_PRIORITY,
MetadataProviderKey,
Permission,
customSortFieldIds,
hasCollectionScopedSort,
isAudioFormat,
isSortField,
jumpRailStrategyForSort,
resolveUploadPath,
} from '@bookorbit/types';
import type {
AudiobookChapter,
BookCommunityRating,
BookKoboState,
BookMetadataRefreshPreviewFields,
BookMetadataRefreshPreviewResponse,
BookMetadataLockField,
BookDeletionAuditMeta,
BookQuery,
BookWriteAndRenameResult,
BooksPage,
CustomMetadataFieldTypeMap,
CoverMedium,
FileRenameResult,
GroupRule,
JumpBucketsResponse,
JumpBucketsQuery,
MetadataFetchDiagnostics,
MetadataField,
ReadStatus,
ReadAloudProgressSync,
ReadAloudProgressSyncMode,
SortSpec,
UserBookStatus,
WriteResult,
EpubMediaOverlayCapability,
} from '@bookorbit/types';
import type { ContentFilterRules } from '@bookorbit/types';
import { assembleBookCards, assembleCollapsedBookCards } from './utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user';
import { UpdateBookFileDto } from './dto/update-book-file.dto';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { BookEmbedderService } from '../embedding/book-embedder.service';
import { MetadataService } from '../metadata/metadata.service';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { LibraryService } from '../library/library.service';
import { books } from '../../db/schema';
import { MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-search-params';
import { FileRenameService, RENAME_RELEVANT_FIELDS } from '../file-write/file-rename.service';
import { FileWriteService } from '../file-write/file-write.service';
import { NarratorService } from '../narrator/narrator.service';
import { UserBookNoteService } from '../user-book-note/user-book-note.service';
import { UserBookStatusService, type AutoReadingActivity } from '../user-book-status/user-book-status.service';
import {
ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED,
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
AchievementEventsService,
} from '../achievement/achievement-events.service';
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
import { selectEmbeddedCoverSources } from '../book-cover-store/cover-sources';
import { CoverSlotReconciler } from '../metadata/cover-slot-reconciler.service';
import { BookQueryBuilder } from './book-query-builder.service';
import { AudiobookEbookProgressSyncService } from './audiobook-ebook-progress-sync.service';
import { AudiolessEpubService } from './audioless-epub.service';
import { BookRepository } from './book.repository';
import { ComicMetadataRepository } from '../metadata/comic-metadata.repository';
import { CustomMetadataService } from '../custom-metadata/custom-metadata.service';
import { BookDetailDto } from './dto/book-detail.dto';
import type { BulkMetadataField } from './dto/bulk-set-metadata.dto';
import type { BulkEditFieldsDto } from './dto/bulk-edit-metadata.dto';
import type { BulkSelectionDto } from '../../common/dto/bulk-selection.dto';
import type { MetadataExportDto, MetadataExportFormat, MetadataExportViewType } from './dto/metadata-export.dto';
import type { MetadataExportColumnMode } from './dto/metadata-export-options.dto';
import { SaveProgressDto } from './dto/save-progress.dto';
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
import { UpdateBookAddedAtDto } from './dto/update-book-added-at.dto';
import { UpdatePersonalNoteDto } from './dto/update-personal-note.dto';
import type { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
import { buildBookDetailSupplementalFields } from './utils/build-book-detail-supplemental-fields';
import type { SetStatusDto } from '../user-book-status/dto/set-status.dto';
import { READING_DATE_ERROR_CODES } from '../user-book-status/user-book-status.constants';
import { inspectEpubMediaOverlayFields, mediaOverlayCapabilityFromFields } from '../reader/epub/epub-media-overlay-capability';
import { computeFileHash } from '../scanner/lib/hash';
type SeriesCollapseQueryOptions = {
seriesSelectionFilter: GroupRule | undefined;
};
type BookQueryExecutionOptions = Partial<SeriesCollapseQueryOptions> & {
defaultCollectionId?: number;
};
const METADATA_UPDATE_FAILPOINTS = [
'afterScalarUpdate',
'afterSeriesMembershipsReplace',
'afterComicMetadataUpsert',
'afterAuthorsReplace',
'afterNarratorsReplace',
'afterGenresReplace',
'afterTagsReplace',
'beforeTransactionCommit',
] as const;
const METADATA_PROVIDER_KEY_SET = new Set<string>(Object.values(MetadataProviderKey));
export type MetadataUpdateFailpoint = (typeof METADATA_UPDATE_FAILPOINTS)[number];
export type ExportScope = 'primary' | 'all' | 'audio';
const READ_ALOUD_MAX_DURATION_DIFF_RATIO = 0.05;
const READ_ALOUD_MAX_DURATION_DIFF_SECONDS = 300;
export type BulkEditFieldResult = { updated: number; skippedLocked: number };
export type BulkEditMetadataResult = {
updatedBooks: number;
fields: Record<string, BulkEditFieldResult>;
};
const BULK_METADATA_LOCK_FIELD_BY_FIELD: Record<BulkMetadataField, BookMetadataLockField> = {
seriesName: 'seriesName',
publisher: 'publisher',
language: 'language',
publishedYear: 'publishedYear',
authors: 'authors',
genres: 'genres',
tags: 'tags',
narrators: 'narrators',
};
const EXPORT_LIMITS = {
MAX_BOOKS: 250,
MAX_FILES: 2000,
MAX_PROJECTED_BYTES: 8 * 1024 * 1024 * 1024,
MAX_CONCURRENT_PER_USER: 2,
} as const;
const MAX_DELETION_AUDIT_BOOKS = 25;
type ExportCandidateFile = {
bookId: number;
absolutePath: string;
format: string | null;
sizeBytes: number | null;
sortOrder?: number;
mediaOverlayAvailable: boolean;
};
export type ExportPlan = {
files: { absolutePath: string; zipPath: string; sizeBytes: number }[];
projectedBytes: number;
bookCount: number;
scope: ExportScope;
archiveFilename: string;
};
const METADATA_EXPORT_SCHEMA_VERSION = 1 as const;
const METADATA_EXPORT_LIMITS = {
MAX_ROWS: 100_000,
MAX_ESTIMATED_BYTES: 120 * 1024 * 1024,
} as const;
type MetadataExportSizeCategory = 'small' | 'medium' | 'large';
type MetadataExportResolvedOptions = {
includePersonalData: boolean;
includeFilePaths: boolean;
includeContextMeta: boolean;
columnsMode: MetadataExportColumnMode;
visibleColumns: string[];
viewType: MetadataExportViewType;
};
type MetadataExportSelectionScope = 'selected' | 'all-matching';
type MetadataExportPreflight = {
schemaVersion: number;
rowCount: number;
estimatedBytes: number;
sizeCategory: MetadataExportSizeCategory;
fileName: string;
scope: MetadataExportSelectionScope;
format: MetadataExportFormat;
};
type MetadataExportContextMeta = {
exportedAt: string;
exportedByUserId: number;
viewType: MetadataExportViewType;
scope: MetadataExportSelectionScope;
format: MetadataExportFormat;
rowCount: number;
options: MetadataExportResolvedOptions;
query?: {
libraryId?: number;
q?: string;
sort?: { field: string; dir: string }[];
filterApplied: boolean;
};
};
type MetadataExportBuildResult = {
preflight: MetadataExportPreflight;
content: string;
contentType: string;
fileName: string;
};
type MetadataSaveResult = {
book: BookDetailDto;
write: WriteResult | null;
libraryAutoWriteEnabled: boolean;
};
type LibraryWriteSettings = {
fileWriteEnabled: boolean;
fileRenameEnabled: boolean;
};
type LibraryWriteSettingsLookupResult = {
settings: LibraryWriteSettings | null;
writeFailure: WriteResult | null;
};
type PostMetadataSaveMode = 'sync' | 'schedule';
export type ProgressStatusFileContext = {
bookId: number;
libraryId: number;
};
@Injectable()
export class BookService {
private readonly logger = new Logger(BookService.name);
private readonly appDataPath: string;
private embeddingRun: Promise<void> | null = null;
private metadataUpdateFailpoint: MetadataUpdateFailpoint | null = null;
private readonly activeExportCounts = new Map<number, number>();
constructor(
private readonly bookRepo: BookRepository,
private readonly libraryService: LibraryService,
private readonly queryBuilder: BookQueryBuilder,
private readonly metadataService: MetadataService,
private readonly scoreService: MetadataScoreService,
private readonly pipeline: MetadataFetchPipeline,
private readonly config: ConfigService,
private readonly appSettings: AppSettingsService,
private readonly userBookStatusService: UserBookStatusService,
private readonly userBookNoteService: UserBookNoteService,
private readonly narratorService: NarratorService,
private readonly comicMetadataService: ComicMetadataRepository,
private readonly customMetadataService: CustomMetadataService,
private readonly bookMetadataLockService: BookMetadataLockService,
private readonly coverStore: BookCoverStore,
@Optional() private readonly embedder: BookEmbedderService,
@Optional() private readonly fileWriteService: FileWriteService,
@Optional() private readonly fileRenameService: FileRenameService,
@Optional() private readonly achievementEvents: AchievementEventsService,
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
@Optional() private readonly seriesExpectedCount?: SeriesExpectedCountService,
@Optional() private readonly audiobookEbookProgressSync?: AudiobookEbookProgressSyncService,
@Optional() private readonly audiolessEpubService?: AudiolessEpubService,
@Optional() private readonly coverReconciler?: CoverSlotReconciler,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
private isSuperuser(user: RequestUser): boolean {
return user.isSuperuser;
}
private hasPermission(user: RequestUser, permissionName: Permission): boolean {
return user.isSuperuser || user.permissions.includes(permissionName);
}
private async checkBookPassesContentFilters(bookId: number, contentFilters: ContentFilterRules): Promise<boolean> {
return this.bookRepo.checkBookPassesContentFilters(bookId, contentFilters);
}
private isMissingFilesystemEntry(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
return code === 'ENOENT' || code === 'ENOTDIR';
}
private async verifyLibraryAccessForBookIds(bookIds: number[], user: RequestUser): Promise<{ id: number; libraryId: number }[]> {
const uniqueBookIds = [...new Set(bookIds)];
const rows = await this.bookRepo.findLibraryIdsByBookIds(uniqueBookIds);
if (rows.length !== uniqueBookIds.length) {
throw new NotFoundException('One or more books were not found');
}
const uniqueLibraryIds = [...new Set(rows.map((row) => row.libraryId))];
const isSuperuser = this.isSuperuser(user);
await Promise.all(uniqueLibraryIds.map((libraryId) => this.libraryService.verifyUserAccess(user.id, libraryId, isSuperuser)));
return rows;
}
private collectExistingProviderIds(meta: {
googleBooksId?: string | null;
goodreadsId?: string | null;
amazonId?: string | null;
hardcoverId?: string | null;
hardcoverEditionId?: string | null;
openLibraryId?: string | null;
itunesId?: string | null;
audibleId?: string | null;
librofmId?: string | null;
koboId?: string | null;
comicvineId?: string | null;
ranobedbId?: string | null;
lubimyczytacId?: string | null;
aladinId?: string | null;
}): Partial<Record<MetadataProviderKey, string>> {
const providerIds: Partial<Record<MetadataProviderKey, string>> = {};
if (meta.googleBooksId) providerIds[MetadataProviderKey.GOOGLE] = meta.googleBooksId;
if (meta.goodreadsId) providerIds[MetadataProviderKey.GOODREADS] = meta.goodreadsId;
if (meta.amazonId) providerIds[MetadataProviderKey.AMAZON] = meta.amazonId;
if (meta.hardcoverId) providerIds[MetadataProviderKey.HARDCOVER] = meta.hardcoverId;
if (meta.openLibraryId) providerIds[MetadataProviderKey.OPEN_LIBRARY] = meta.openLibraryId;
if (meta.itunesId) providerIds[MetadataProviderKey.ITUNES] = meta.itunesId;
if (meta.audibleId) providerIds[MetadataProviderKey.AUDIBLE] = meta.audibleId;
if (meta.librofmId) providerIds[MetadataProviderKey.LIBROFM] = meta.librofmId;
if (meta.koboId) providerIds[MetadataProviderKey.KOBO] = meta.koboId;
if (meta.comicvineId) providerIds[MetadataProviderKey.COMICVINE] = meta.comicvineId;
if (meta.ranobedbId) providerIds[MetadataProviderKey.RANOBEDB] = meta.ranobedbId;
if (meta.lubimyczytacId) providerIds[MetadataProviderKey.LUBIMYCZYTAC] = meta.lubimyczytacId;
if (meta.aladinId) providerIds[MetadataProviderKey.ALADIN] = meta.aladinId;
return providerIds;
}
private applyResolvedProviderIds(
dto: Pick<
UpdateBookMetadataDto,
| 'googleBooksId'
| 'goodreadsId'
| 'amazonId'
| 'hardcoverId'
| 'hardcoverEditionId'
| 'openLibraryId'
| 'itunesId'
| 'audibleId'
| 'librofmId'
| 'koboId'
| 'comicvineId'
| 'ranobedbId'
| 'lubimyczytacId'
| 'aladinId'
>,
providerIds: Partial<Record<MetadataProviderKey, string>>,
): void {
if (providerIds[MetadataProviderKey.GOOGLE]) dto.googleBooksId = providerIds[MetadataProviderKey.GOOGLE];
if (providerIds[MetadataProviderKey.GOODREADS]) dto.goodreadsId = providerIds[MetadataProviderKey.GOODREADS];
if (providerIds[MetadataProviderKey.AMAZON]) dto.amazonId = providerIds[MetadataProviderKey.AMAZON];
if (providerIds[MetadataProviderKey.HARDCOVER]) dto.hardcoverId = providerIds[MetadataProviderKey.HARDCOVER];
if (providerIds[MetadataProviderKey.OPEN_LIBRARY]) dto.openLibraryId = providerIds[MetadataProviderKey.OPEN_LIBRARY];
if (providerIds[MetadataProviderKey.ITUNES]) dto.itunesId = providerIds[MetadataProviderKey.ITUNES];
if (providerIds[MetadataProviderKey.AUDIBLE]) dto.audibleId = providerIds[MetadataProviderKey.AUDIBLE];
if (providerIds[MetadataProviderKey.LIBROFM]) dto.librofmId = providerIds[MetadataProviderKey.LIBROFM];
if (providerIds[MetadataProviderKey.KOBO]) dto.koboId = providerIds[MetadataProviderKey.KOBO];
if (providerIds[MetadataProviderKey.COMICVINE]) dto.comicvineId = providerIds[MetadataProviderKey.COMICVINE];
if (providerIds[MetadataProviderKey.RANOBEDB]) dto.ranobedbId = providerIds[MetadataProviderKey.RANOBEDB];
if (providerIds[MetadataProviderKey.LUBIMYCZYTAC]) dto.lubimyczytacId = providerIds[MetadataProviderKey.LUBIMYCZYTAC];
if (providerIds[MetadataProviderKey.ALADIN]) dto.aladinId = providerIds[MetadataProviderKey.ALADIN];
}
private buildMetadataRefreshPreview(
resolved: ResolvedMetadataFields,
providerIds: Partial<Record<MetadataProviderKey, string>>,
): BookMetadataRefreshPreviewFields {
const r = resolved as Record<string, unknown>;
const preview: BookMetadataRefreshPreviewFields = {};
if (r.title !== undefined) preview.title = r.title as string | null;
if (r.subtitle !== undefined) preview.subtitle = r.subtitle as string | null;
if (r.description !== undefined) preview.description = r.description as string | null;
if (r.authors !== undefined) preview.authors = r.authors as string[];
if (r.genres !== undefined) preview.genres = r.genres as string[];
if (r.publisher !== undefined) preview.publisher = r.publisher as string | null;
if (r.publishedDate !== undefined) preview.publishedDate = r.publishedDate as string | null;
if (r.publishedYear !== undefined) preview.publishedYear = r.publishedYear as number | null;
if (r.language !== undefined) preview.language = r.language as string | null;
if (r.pageCount !== undefined) preview.pageCount = r.pageCount as number | null;
if (r.communityRatings !== undefined) preview.communityRatings = r.communityRatings as BookCommunityRating[];
if (r.seriesName !== undefined) preview.seriesName = r.seriesName as string | null;
if (r.seriesIndex !== undefined) preview.seriesIndex = r.seriesIndex as string | null;
if (r.seriesMemberships !== undefined) preview.seriesMemberships = r.seriesMemberships as BookMetadataRefreshPreviewFields['seriesMemberships'];
if (r.coverUrl !== undefined) preview.coverUrl = r.coverUrl as string;
if (r.audioCoverUrl !== undefined) preview.audioCoverUrl = r.audioCoverUrl as string;
if (r.hardcoverEditionId !== undefined) preview.hardcoverEditionId = r.hardcoverEditionId as string | null;
if (r.comicMetadata !== undefined) preview.comicMetadata = r.comicMetadata as BookMetadataRefreshPreviewFields['comicMetadata'];
if (r.narrators !== undefined || r.duration !== undefined || r.abridged !== undefined || r.chapters !== undefined) {
preview.audioMetadata = {};
if (r.narrators !== undefined) preview.audioMetadata.narrators = r.narrators as string[];
if (r.duration !== undefined) preview.audioMetadata.durationSeconds = r.duration as number | null;
if (r.abridged !== undefined) preview.audioMetadata.abridged = r.abridged as boolean | null;
if (r.chapters !== undefined) preview.audioMetadata.chapters = r.chapters as AudiobookChapter[];
}
this.applyResolvedProviderIds(preview, providerIds);
return preview;
}
private buildMetadataRefreshPreviewDiagnostics(
metadata: BookMetadataRefreshPreviewFields,
diagnostics?: MetadataFetchDiagnostics,
): MetadataFetchDiagnostics {
const resolvedFieldCount = Object.keys(metadata).length;
const base = diagnostics ?? this.emptyMetadataFetchDiagnostics();
return {
...base,
resolvedFieldCount,
reason: resolvedFieldCount > 0 ? null : base.reason,
};
}
private emptyMetadataFetchDiagnostics(): MetadataFetchDiagnostics {
return {
reason: 'no_active_providers',
activeProviders: [],
fieldRuleProviders: [],
disabledFieldRuleProviders: [],
enabledUnreferencedProviders: [],
throttledProviders: [],
candidateProviders: [],
candidateCount: 0,
resolvedFieldCount: 0,
};
}
private normalizeCommunityRatingInputs(
ratings: UpdateBookMetadataDto['communityRatings'],
): Array<{ provider: MetadataProviderKey; rating: number; ratingCount: number | null }> | undefined {
if (ratings === undefined) return undefined;
if (!ratings?.length) return [];
const byProvider = new Map<MetadataProviderKey, { provider: MetadataProviderKey; rating: number; ratingCount: number | null }>();
for (const item of ratings) {
if (!METADATA_PROVIDER_KEY_SET.has(item.provider)) continue;
if (!Number.isFinite(item.rating) || item.rating < 0 || item.rating > 5) continue;
const ratingCount =
typeof item.ratingCount === 'number' && Number.isInteger(item.ratingCount) && item.ratingCount >= 0 ? item.ratingCount : null;
byProvider.set(item.provider, { provider: item.provider, rating: item.rating, ratingCount });
}
return [...byProvider.values()];
}
private mapCommunityRatingRows(
rows: readonly { provider: string; rating: number; ratingCount: number | null; updatedAt: Date | null }[],
): BookCommunityRating[] {
return rows
.filter((row): row is { provider: MetadataProviderKey; rating: number; ratingCount: number | null; updatedAt: Date | null } =>
METADATA_PROVIDER_KEY_SET.has(row.provider),
)
.map((row) => ({
provider: row.provider,
rating: row.rating,
ratingCount: row.ratingCount,
updatedAt: row.updatedAt?.toISOString() ?? null,
}));
}
private normalizeUpdatePublishedDate(value: string | null | undefined): string | null {
const normalized = normalizePublishedDate(value);
if (normalized === undefined) return null;
if (normalized === null && value !== null && value !== undefined && value.trim() !== '') {
throw new BadRequestException('Invalid publishedDate value');
}
return normalized;
}
async verifyBookAccess(bookId: number, user: RequestUser): Promise<void> {
const libraryId = await this.bookRepo.findLibraryIdByBookId(bookId);
if (libraryId === null) throw new NotFoundException(`Book ${bookId} not found`);
// Not found, rather than forbidden. A 403 tells the caller the book exists, and book ids are
// sequential integers, so the id space can be walked to learn which books a server holds. The
// upload session routes already answer 404 for another account's session, and the content
// filter branch below already answers 404 here, so this is the one denial reason in this method
// that was disclosing existence. Measured as F-013.
try {
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
} catch (error) {
if (error instanceof ForbiddenException) {
throw new NotFoundException(`Book ${bookId} not found`);
}
throw error;
}
if (!this.isSuperuser(user) && user.contentFilters) {
const passes = await this.checkBookPassesContentFilters(bookId, user.contentFilters);
if (!passes) throw new NotFoundException(`Book ${bookId} not found`);
}
}
setHardcoverEditionIdIfEmpty(bookId: number, hardcoverEditionId: string): Promise<boolean> {
return this.bookRepo.setHardcoverEditionIdIfEmpty(bookId, hardcoverEditionId);
}
async verifyFileAccess(fileId: number, user: RequestUser): Promise<NonNullable<Awaited<ReturnType<BookRepository['findFileById']>>>> {
const file = await this.bookRepo.findFileById(fileId);
if (!file) throw new NotFoundException(`No file with id ${fileId}`);
await this.libraryService.verifyUserAccess(user.id, file.libraryId, this.isSuperuser(user));
if (!this.isSuperuser(user) && user.contentFilters) {
const passes = await this.checkBookPassesContentFilters(file.bookId, user.contentFilters);
if (!passes) throw new NotFoundException(`No file with id ${fileId}`);
}
return file;
}
async resolveSelectionToIds(dto: BulkSelectionDto, user: RequestUser): Promise<number[]> {
if (dto.bookIds && dto.query) {
throw new BadRequestException('bookIds and query are mutually exclusive');
}
if (dto.bookIds) {
await this.verifyLibraryAccessForBookIds(dto.bookIds, user);
return dto.bookIds;
}
if (dto.query) {
const libs = await this.libraryService.findAll(user);
let accessibleLibraryIds = libs.map((l) => l.id);
const timeZone = this.resolveUserTimeZone(user);
if (dto.query.libraryId !== undefined) {
if (!accessibleLibraryIds.includes(dto.query.libraryId)) {
throw new ForbiddenException(`Library ${dto.query.libraryId} is not accessible`);
}
accessibleLibraryIds = [dto.query.libraryId];
}
const where = this.queryBuilder.buildWhere(dto.query.filter, {
accessibleLibraryIds,
implicitLibraryId: dto.query.libraryId,
userId: user.id,
q: dto.query.q,
timeZone,
contentFilters: this.isSuperuser(user) ? undefined : user.contentFilters,
});
return this.bookRepo.findIdsByWhere(where);
}
throw new BadRequestException('Either bookIds or query must be provided');
}
acquireExportSlot(userId: number): () => void {
const current = this.activeExportCounts.get(userId) ?? 0;
if (current >= EXPORT_LIMITS.MAX_CONCURRENT_PER_USER) {
throw new HttpException(
`Too many concurrent exports. Limit is ${EXPORT_LIMITS.MAX_CONCURRENT_PER_USER} active exports per user.`,
HttpStatus.TOO_MANY_REQUESTS,
);
}
this.activeExportCounts.set(userId, current + 1);
let released = false;
return () => {
if (released) return;
released = true;
const next = (this.activeExportCounts.get(userId) ?? 1) - 1;
if (next <= 0) {
this.activeExportCounts.delete(userId);
} else {
this.activeExportCounts.set(userId, next);
}
};
}
private resolveMetadataExportOptions(dto: MetadataExportDto): MetadataExportResolvedOptions {
return {
includePersonalData: dto.options?.includePersonalData ?? false,
includeFilePaths: dto.options?.includeFilePaths ?? false,
includeContextMeta: dto.options?.includeContextMeta ?? true,
columnsMode: dto.options?.columnsMode ?? 'canonical',
visibleColumns: dto.options?.visibleColumns ?? [],
viewType: dto.viewType ?? 'library',
};
}
private resolveMetadataExportScope(dto: BulkSelectionDto): MetadataExportSelectionScope {
return dto.query ? 'all-matching' : 'selected';
}
private resolveMetadataExportFileName(viewType: MetadataExportViewType, scope: MetadataExportSelectionScope, format: MetadataExportFormat): string {
const date = new Date().toISOString().slice(0, 10);
return `bookorbit-${viewType}-${scope}-${date}.${format}`;
}
private estimateMetadataExportBytes(rowCount: number, format: MetadataExportFormat, options: MetadataExportResolvedOptions): number {
const basePerRow = format === 'json' ? 1150 : 520;
const personalExtra = options.includePersonalData ? 160 : 0;
const pathExtra = options.includeFilePaths ? 260 : 0;
const contextExtra = options.includeContextMeta ? 2200 : 0;
return Math.max(0, rowCount * (basePerRow + personalExtra + pathExtra) + contextExtra);
}
private resolveMetadataExportSizeCategory(estimatedBytes: number): MetadataExportSizeCategory {
if (estimatedBytes < 5 * 1024 * 1024) return 'small';
if (estimatedBytes < 25 * 1024 * 1024) return 'medium';
return 'large';
}
private normalizeExportSort(sort: unknown): BookQuery['sort'] {
if (!Array.isArray(sort)) return [];
return sort
.map((entry) => {
const rawField = (entry as { field?: unknown })?.field;
const field = typeof rawField === 'string' && isSortField(rawField) ? rawField : null;
const dir =
typeof (entry as { dir?: unknown })?.dir === 'string' ? ((entry as { dir: string }).dir as BookQuery['sort'][number]['dir']) : null;
if (!field || !dir) return null;
if (dir !== 'asc' && dir !== 'desc') return null;
return { field, dir };
})
.filter((entry): entry is BookQuery['sort'][number] => entry !== null);
}
private async resolveMetadataExportRows(
dto: MetadataExportDto,
user: RequestUser,
): Promise<{
rows: BooksPage['items'];
rowCount: number;
scope: MetadataExportSelectionScope;
queryMeta?: MetadataExportContextMeta['query'];
}> {
if (dto.query) {
const libraries = await this.libraryService.findAll(user);
let accessibleLibraryIds = libraries.map((library) => library.id);
const timeZone = this.resolveUserTimeZone(user);
if (dto.query.libraryId !== undefined) {
if (!accessibleLibraryIds.includes(dto.query.libraryId)) {
throw new ForbiddenException(`Library ${dto.query.libraryId} is not accessible`);
}
accessibleLibraryIds = [dto.query.libraryId];
}
const where = this.queryBuilder.buildWhere(dto.query.filter, {
accessibleLibraryIds,
implicitLibraryId: dto.query.libraryId,
userId: user.id,
q: dto.query.q,
timeZone,
});
const rowCount = await this.bookRepo.countWhere(where);
if (rowCount === 0) throw new BadRequestException('No books matched export selection');
if (rowCount > METADATA_EXPORT_LIMITS.MAX_ROWS) {
throw new BadRequestException(`Too many rows selected for metadata export. Limit is ${METADATA_EXPORT_LIMITS.MAX_ROWS}.`);
}
const sort = this.normalizeExportSort(dto.query.sort);
const page = await this.executeBooksQuery(user.id, where, {
filter: dto.query.filter,
sort,
pagination: { page: 0, size: rowCount },
q: dto.query.q,
});
return {
rows: page.items,
rowCount,
scope: 'all-matching',
queryMeta: {
libraryId: dto.query.libraryId,
q: dto.query.q,
sort: sort.map((spec) => ({ field: spec.field, dir: spec.dir })),
filterApplied: !!dto.query.filter,
},
};
}
const uniqueIds = [...new Set(dto.bookIds ?? [])];
if (uniqueIds.length === 0) throw new BadRequestException('No books selected for metadata export');
if (uniqueIds.length > METADATA_EXPORT_LIMITS.MAX_ROWS) {
throw new BadRequestException(`Too many rows selected for metadata export. Limit is ${METADATA_EXPORT_LIMITS.MAX_ROWS}.`);
}
const rows = await this.verifyLibraryAccessForBookIds(uniqueIds, user);
const foundIds = new Set(rows.map((row) => row.id));
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
if (missingIds.length > 0) {
throw new BadRequestException(`Some selected books were not found: ${missingIds.join(', ')}`);
}
const sort = this.normalizeExportSort(dto.sort);
const page = await this.executeBooksQuery(user.id, inArray(books.id, uniqueIds), {
filter: undefined,
sort,
pagination: { page: 0, size: uniqueIds.length },
});
return {
rows: page.items,
rowCount: page.items.length,
scope: 'selected',
};
}
private async resolveMetadataExportSelectionCount(
dto: MetadataExportDto,
user: RequestUser,
): Promise<{
rowCount: number;
scope: MetadataExportSelectionScope;
}> {
if (dto.query) {
const libraries = await this.libraryService.findAll(user);
let accessibleLibraryIds = libraries.map((library) => library.id);
const timeZone = this.resolveUserTimeZone(user);
if (dto.query.libraryId !== undefined) {
if (!accessibleLibraryIds.includes(dto.query.libraryId)) {
throw new ForbiddenException(`Library ${dto.query.libraryId} is not accessible`);
}
accessibleLibraryIds = [dto.query.libraryId];
}
const where = this.queryBuilder.buildWhere(dto.query.filter, {
accessibleLibraryIds,
implicitLibraryId: dto.query.libraryId,
userId: user.id,
q: dto.query.q,
timeZone,
});
const rowCount = await this.bookRepo.countWhere(where);
if (rowCount === 0) throw new BadRequestException('No books matched export selection');
return { rowCount, scope: 'all-matching' };
}
const uniqueIds = [...new Set(dto.bookIds ?? [])];
if (uniqueIds.length === 0) throw new BadRequestException('No books selected for metadata export');
await this.verifyLibraryAccessForBookIds(uniqueIds, user);
const rows = await this.bookRepo.findLibraryIdsByBookIds(uniqueIds);
const foundIds = new Set(rows.map((row) => row.id));
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
if (missingIds.length > 0) {
throw new BadRequestException(`Some selected books were not found: ${missingIds.join(', ')}`);
}
return { rowCount: uniqueIds.length, scope: 'selected' };
}
private resolveVisibleExportKeys(visibleColumns: string[], options: MetadataExportResolvedOptions): string[] {
const keyMap: Record<string, string[]> = {
title: ['title'],
subtitle: ['subtitle'],
authors: ['authors'],
seriesName: ['seriesName'],
seriesIndex: ['seriesIndex'],
publishedDate: ['publishedDate'],
publishedYear: ['publishedYear'],
language: ['language'],
genres: ['genres'],
tags: ['tags'],
narrators: ['narrators'],
publisher: ['publisher'],
pageCount: ['pageCount'],
isbn13: ['isbn13'],
metadataScore: ['metadataScore'],
addedAt: ['addedAt'],
updatedAt: ['updatedAt'],
format: ['primaryFormat', 'formats'],
fileSize: ['totalFileSizeBytes'],
rating: ['rating'],
readingProgress: ['readProgress'],
finishedAt: ['readFinishedAt'],
readStatus: ['readStatus'],
};
const required = ['bookId', 'libraryId', 'libraryName'];
const resolved = new Set<string>(required);
for (const columnId of visibleColumns) {
for (const key of keyMap[columnId] ?? []) {
resolved.add(key);
}
}
if (options.includeFilePaths) {
resolved.add('folderPath');
resolved.add('filePaths');
}
return [...resolved];
}
private resolveCanonicalExportKeys(options: MetadataExportResolvedOptions, customKeys: string[] = []): string[] {
const keys = [
'bookId',
'libraryId',
'libraryName',
'status',
'title',
'subtitle',
'authors',
'seriesName',
'seriesIndex',
'publishedDate',
'publishedYear',
'language',
'publisher',
'pageCount',
'isbn13',
'hardcoverId',
'hardcoverEditionId',
'genres',
'tags',
'narrators',
'metadataScore',
'hasCover',
'hasMetadataLocks',
'lockedFields',
'addedAt',
'updatedAt',
'fileCount',
'primaryFormat',
'formats',
'totalFileSizeBytes',
'rating',
'readProgress',
'readStatus',
'readStartedAt',
'readFinishedAt',
'readUpdatedAt',
...customKeys,
];
if (!options.includePersonalData) {
// Keep schema stable by retaining personal-data fields but emitting null values.
}
if (options.includeFilePaths) {
keys.push('folderPath', 'filePaths');
}
return keys;
}
private csvCell(value: string | number | boolean | null | undefined): string {
if (value === null || value === undefined) return '';
const text = String(value);
if (!/[",\n]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
private projectExportRow(record: Record<string, unknown>, keys: string[]): Record<string, unknown> {
const projected: Record<string, unknown> = {};
for (const key of keys) projected[key] = record[key];
return projected;
}
private serializeCsvRows(rows: Record<string, unknown>[], keys: string[], meta: MetadataExportContextMeta, includeContextMeta: boolean): string {
const lines: string[] = ['\uFEFF'];
if (includeContextMeta) {
lines.push(`# schemaVersion=${METADATA_EXPORT_SCHEMA_VERSION}`);
lines.push(`# exportedAt=${meta.exportedAt}`);
lines.push(`# viewType=${meta.viewType}`);
lines.push(`# scope=${meta.scope}`);
lines.push(`# rowCount=${meta.rowCount}`);
}
lines.push(keys.join(','));
for (const row of rows) {
const values = keys.map((key) => {
const value = row[key];
if (Array.isArray(value)) return this.csvCell(value.join('; '));
if (value !== null && typeof value === 'object') return this.csvCell(JSON.stringify(value));
return this.csvCell(value as string | number | boolean | null | undefined);
});
lines.push(values.join(','));
}
return lines.join('\n');
}
async getMetadataExportPreflight(dto: MetadataExportDto, user: RequestUser): Promise<MetadataExportPreflight> {
const options = this.resolveMetadataExportOptions(dto);
const { rowCount, scope } = await this.resolveMetadataExportSelectionCount(dto, user);
if (rowCount > METADATA_EXPORT_LIMITS.MAX_ROWS) {
throw new BadRequestException(`Too many rows selected for metadata export. Limit is ${METADATA_EXPORT_LIMITS.MAX_ROWS}.`);
}
const estimatedBytes = this.estimateMetadataExportBytes(rowCount, dto.format, options);
if (estimatedBytes > METADATA_EXPORT_LIMITS.MAX_ESTIMATED_BYTES) {
throw new BadRequestException(
`Metadata export is too large. Estimated size exceeds ${METADATA_EXPORT_LIMITS.MAX_ESTIMATED_BYTES} bytes. Refine your filters.`,
);
}
return {
schemaVersion: METADATA_EXPORT_SCHEMA_VERSION,
rowCount,
estimatedBytes,
sizeCategory: this.resolveMetadataExportSizeCategory(estimatedBytes),
fileName: this.resolveMetadataExportFileName(options.viewType, scope, dto.format),
scope,
format: dto.format,
};
}
async buildMetadataExport(dto: MetadataExportDto, user: RequestUser): Promise<MetadataExportBuildResult> {
const options = this.resolveMetadataExportOptions(dto);
const { rows, rowCount, scope, queryMeta } = await this.resolveMetadataExportRows(dto, user);
const estimatedBytes = this.estimateMetadataExportBytes(rowCount, dto.format, options);
if (estimatedBytes > METADATA_EXPORT_LIMITS.MAX_ESTIMATED_BYTES) {
throw new BadRequestException(
`Metadata export is too large. Estimated size exceeds ${METADATA_EXPORT_LIMITS.MAX_ESTIMATED_BYTES} bytes. Refine your filters.`,
);
}
const preflight: MetadataExportPreflight = {
schemaVersion: METADATA_EXPORT_SCHEMA_VERSION,
rowCount,
estimatedBytes,
sizeCategory: this.resolveMetadataExportSizeCategory(estimatedBytes),
fileName: this.resolveMetadataExportFileName(options.viewType, scope, dto.format),
scope,
format: dto.format,
};
const bookIds = rows.map((row) => row.id);
const [libraryRows, libraries, filePathRows, customValuesByBookId] = await Promise.all([
this.bookRepo.findLibraryIdsByBookIds(bookIds),
this.libraryService.findAll(user),
options.includeFilePaths ? this.bookRepo.findAllFilesByBookIds(bookIds) : Promise.resolve([]),
this.customMetadataService.getExportValues(bookIds),
]);
const libraryIdByBookId = new Map(libraryRows.map((row) => [row.id, row.libraryId]));
const libraryNameById = new Map(libraries.map((library) => [library.id, library.name]));
const filePathsByBookId = new Map<number, string[]>();
for (const file of filePathRows) {
const byBook = filePathsByBookId.get(file.bookId) ?? [];
byBook.push(file.absolutePath);
filePathsByBookId.set(file.bookId, byBook);
}
const records = rows.map((row) => {
const libraryId = libraryIdByBookId.get(row.id) ?? null;
const primaryFile = row.files.find((file) => file.role === 'primary') ?? row.files[0] ?? null;
const formats = [...new Set(row.files.map((file) => file.format).filter((format): format is string => !!format))];
const totalFileSizeBytes = row.files.reduce((sum, file) => sum + (file.sizeBytes ?? 0), 0);
const readStatus = options.includePersonalData ? (row.readStatus?.status ?? null) : null;
const readStartedAt = options.includePersonalData ? (row.readStatus?.startedAt ?? null) : null;
const readFinishedAt = options.includePersonalData ? (row.readStatus?.finishedAt ?? null) : null;
const readUpdatedAt = options.includePersonalData ? (row.readStatus?.updatedAt ?? null) : null;
const rating = options.includePersonalData ? (row.rating ?? null) : null;
const readProgress = options.includePersonalData ? (row.readingProgress ?? null) : null;
const filePaths = options.includeFilePaths ? (filePathsByBookId.get(row.id) ?? []) : [];
const folderPath = options.includeFilePaths && filePaths.length > 0 ? dirname(filePaths[0]!) : null;
const customValues = customValuesByBookId.get(row.id) ?? {};
return {
bookId: row.id,
libraryId,
libraryName: libraryId !== null ? (libraryNameById.get(libraryId) ?? null) : null,
status: row.status,
title: row.title,
subtitle: row.subtitle,
authors: row.authors,
seriesName: row.seriesName,
seriesIndex: row.seriesIndex,
publishedDate: row.publishedDate,
publishedYear: row.publishedYear,
language: row.language,
publisher: row.publisher,
pageCount: row.pageCount,
isbn13: row.isbn13,
hardcoverId: row.hardcoverId ?? null,
hardcoverEditionId: row.hardcoverEditionId ?? null,
genres: row.genres,
tags: row.tags,
narrators: row.narrators,
metadataScore: row.metadataScore,
hasCover: row.hasCover,
hasMetadataLocks: row.hasMetadataLocks,
lockedFields: row.lockedFields,
addedAt: row.addedAt,
updatedAt: row.updatedAt,
fileCount: row.files.length,
primaryFormat: primaryFile?.format ?? null,
formats,
totalFileSizeBytes,
rating,
readProgress,
readStatus,
readStartedAt,
readFinishedAt,
readUpdatedAt,
folderPath,
filePaths,
files: row.files.map((file) => ({
id: file.id,
format: file.format,
role: file.role,
sizeBytes: file.sizeBytes,
})),
...customValues,
} as Record<string, unknown>;
});
const customExportKeys = [...new Set(records.flatMap((record) => Object.keys(record).filter((key) => key.startsWith('custom.'))))].sort();
const exportKeys =
options.columnsMode === 'visible'
? this.resolveVisibleExportKeys(options.visibleColumns, options)
: this.resolveCanonicalExportKeys(options, customExportKeys);
const projected = records.map((record) => this.projectExportRow(record, exportKeys));
const contextMeta: MetadataExportContextMeta = {
exportedAt: new Date().toISOString(),
exportedByUserId: user.id,
viewType: options.viewType,
scope,
format: dto.format,
rowCount,
options,
query: queryMeta,
};
if (dto.format === 'json') {
const payload: { schemaVersion: number; items: Record<string, unknown>[]; meta?: MetadataExportContextMeta } = {
schemaVersion: METADATA_EXPORT_SCHEMA_VERSION,
items: projected,
};
if (options.includeContextMeta) {
payload.meta = contextMeta;
}
const content = JSON.stringify(payload, null, 2);
return {
preflight,
content,
contentType: 'application/json; charset=utf-8',
fileName: preflight.fileName,
};
}
const content = this.serializeCsvRows(projected, exportKeys, contextMeta, options.includeContextMeta);
return {
preflight,
content,
contentType: 'text/csv; charset=utf-8',
fileName: preflight.fileName,
};
}
private assertPaginationWindow(page: number, size: number): void {
if (!isBookQueryOffsetWithinLimit(page * size)) {
throw new BadRequestException(`pagination window is too deep; page * size must be <= ${MAX_BOOK_QUERY_OFFSET_ROWS}`);
}
}
async queryForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise<BooksPage> {
this.assertPaginationWindow(query.pagination.page, query.pagination.size);
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
const timeZone = this.resolveUserTimeZone(user);
const where = this.queryBuilder.buildWhere(query.filter, {
accessibleLibraryIds: [libraryId],
implicitLibraryId: libraryId,
userId: user.id,
q: query.q,
timeZone,
contentFilters: this.isSuperuser(user) ? undefined : user.contentFilters,
});
return this.executeBooksQuery(user.id, where, query);
}
async globalQuery(user: RequestUser, query: BookQuery): Promise<BooksPage> {
this.assertPaginationWindow(query.pagination.page, query.pagination.size);
const libs = await this.libraryService.findAll(user);
const accessibleLibraryIds = libs.map((l) => l.id);
const timeZone = this.resolveUserTimeZone(user);
const where = this.queryBuilder.buildWhere(query.filter, {
accessibleLibraryIds,
userId: user.id,
q: query.q,
timeZone,
contentFilters: this.isSuperuser(user) ? undefined : user.contentFilters,
});
return this.executeBooksQuery(user.id, where, query);
}
/**
* Custom metadata sort tiers reference a field id; the value column depends on the
* field's type, so resolve it here. Only hit the database when a sort actually uses one.
*/
private async resolveCustomSortFieldTypes(sort: SortSpec[]): Promise<CustomMetadataFieldTypeMap | undefined> {
const fieldIds = customSortFieldIds(sort);
if (fieldIds.length === 0) return undefined;
return this.customMetadataService.getActiveFieldTypes(fieldIds);
}
async executeBooksQuery(userId: number, where: SQL | undefined, query: BookQuery, options?: BookQueryExecutionOptions): Promise<BooksPage> {
const start = Date.now();
const { page, size } = query.pagination;
if (hasCollectionScopedSort(query.sort) && options?.defaultCollectionId === undefined) {
throw new BadRequestException('This sort is only available inside a collection');
}
// An absent key means "fall back to the query filter"; a present key set to undefined means the
// caller resolved the selection filter to nothing, which is not the same thing.
const seriesSelectionFilter = options && 'seriesSelectionFilter' in options ? options.seriesSelectionFilter : query.filter;
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesSelectionFilter(seriesSelectionFilter);
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
if (shouldCollapse) {
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } =
await this.bookRepo.findCardsCollapsed({
where,
sort: query.sort,
limit: size,
offset: page * size,
userId,
customFieldTypes,
...(options?.defaultCollectionId !== undefined ? { defaultCollectionId: options.defaultCollectionId } : {}),
...(query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {}),
});
// Collapsed rows render BookTableCollapsedSeriesCell which does not display custom metadata.
const cards = assembleCollapsedBookCards(
rows,
authorRows,
fileRows,
genreRows,
progressRows,
statusRows,
narratorRows,
tagRows,
seriesMembershipRows,
);
await this.coverStore.enrichCardVersions(cards);
const result = {
items: cards,
total,
page,
size,
};
const durationMs = Date.now() - start;
if (durationMs >= 500) {
this.logger.warn(
`[book.list_query] [end] userId=${userId} page=${page} size=${size} filterCount=${query.filter?.rules?.length ?? 0} sortFields=${query.sort?.length ?? 0} collapseSeries=true resultCount=${result.items.length} durationMs=${durationMs} - slow query`,
);
}
return result;
}
const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, {
...(options?.defaultCollectionId !== undefined ? { defaultCollectionId: options.defaultCollectionId } : {}),
...(query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {}),
...(query.q !== undefined ? { query: query.q } : {}),
});
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } =
await this.bookRepo.findCards({
where,
orderBy,
limit: size,
offset: page * size,
userId,
});
const bookIds = rows.map((r) => r.id);
const customMetadataRows = await this.customMetadataService.getCardValues(bookIds);
const cards = assembleBookCards(
rows,
authorRows,
fileRows,
genreRows,
progressRows,
statusRows,
narratorRows,
tagRows,
seriesMembershipRows,
customMetadataRows,
);
await this.coverStore.enrichCardVersions(cards);
const result = {
items: cards,
total,
page,
size,
};
const durationMs = Date.now() - start;
if (durationMs >= 500) {
this.logger.warn(
`[book.list_query] [end] userId=${userId} page=${page} size=${size} filterCount=${query.filter?.rules?.length ?? 0} sortFields=${query.sort?.length ?? 0} collapseSeries=false resultCount=${result.items.length} durationMs=${durationMs} - slow query`,
);
}
return result;
}
async executeBookIdsQuery(userId: number, where: SQL | undefined, query: BookQuery): Promise<number[]> {
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, {
...(query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {}),
});
return this.bookRepo.findCardIds({
where,
orderBy,
limit: query.pagination.size,
offset: query.pagination.page * query.pagination.size,
userId,
});
}
async queryJumpBucketsForLibrary(user: RequestUser, libraryId: number, query: JumpBucketsQuery): Promise<JumpBucketsResponse> {
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
const timeZone = this.resolveUserTimeZone(user);
const where = this.queryBuilder.buildWhere(query.filter, {
accessibleLibraryIds: [libraryId],
implicitLibraryId: libraryId,
userId: user.id,
q: query.q,
timeZone,
contentFilters: this.isSuperuser(user) ? undefined : user.contentFilters,
});
return this.executeJumpBucketsQuery(user.id, where, query, timeZone);
}
async executeJumpBucketsQuery(
userId: number,
where: SQL | undefined,
query: JumpBucketsQuery,
timeZone = 'UTC',
collapseOptions?: SeriesCollapseQueryOptions,
): Promise<JumpBucketsResponse> {
const event = 'book.jump_buckets';
const strategy = jumpRailStrategyForSort(query.sort);
const kind = strategy?.kind ?? null;
const primary = query.sort[0] ?? { field: 'title' as const, dir: 'asc' as const };
const seriesSelectionFilter = collapseOptions ? collapseOptions.seriesSelectionFilter : query.filter;
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesSelectionFilter(seriesSelectionFilter);
if (!strategy) throw new BadRequestException('jump buckets are not available for this sort');
const start = Date.now();
try {
let response: JumpBucketsResponse;
if (strategy.kind === 'temporal') {
const temporalOpts = {
where,
field: primary.field,
direction: primary.dir,
precision: strategy.precision,
userId,
timeZone,
maxBuckets: query.maxBuckets,
};
response = shouldCollapse
? await this.bookRepo.findTemporalJumpBucketsCollapsed(temporalOpts)
: await this.bookRepo.findTemporalJumpBuckets(temporalOpts);
} else {
const discreteOpts = {
where,
field: primary.field,
kind: strategy.kind,
userId,
maxBuckets: query.maxBuckets,
};
// The primary sort decides bucket boundaries, but secondary tiers still order
// rows inside a bucket, so they can reference custom fields. The temporal path
// never applies the full sort, which is why this only matters here.
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
const randomSeedOption = query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {};
response = shouldCollapse
? await this.bookRepo.findJumpBucketsCollapsed({ ...discreteOpts, sort: query.sort, customFieldTypes, ...randomSeedOption })
: await this.bookRepo.findJumpBuckets({
...discreteOpts,
orderBy: this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, {
...randomSeedOption,
...(query.q !== undefined ? { query: query.q } : {}),
}),
});
}
this.logger.log(
`[${event}] [end] userId=${userId} kind=${kind} collapse=${shouldCollapse} maxBuckets=${query.maxBuckets} durationMs=${Date.now() - start} bucketCount=${response.buckets.length} total=${response.total} - jump buckets computed`,
);
return response;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] userId=${userId} kind=${kind} collapse=${shouldCollapse} durationMs=${Date.now() - start} errorClass=${errorClass} error="${errorMessage}" - jump buckets failed`,
);
throw err;
}
}
async getCoverPath(id: number, user: RequestUser, options: { medium?: CoverMedium; strict?: boolean } = {}): Promise<string | null> {
await this.verifyBookAccess(id, user);
return this.coverStore.resolve(id, { variant: 'cover', ...options });
}
async getThumbnailPath(id: number, user: RequestUser, options: { medium?: CoverMedium; strict?: boolean } = {}): Promise<string | null> {
await this.verifyBookAccess(id, user);
return this.coverStore.resolve(id, { variant: 'thumbnail', ...options });
}
private sanitizeFilenameSegment(raw: string, fallback = 'download'): string {
const fallbackSafe =
fallback
.replace(/[/\\:*?"<>|\0]/g, '_')
.trim()
.slice(0, 255) || 'download';
const cleaned = raw
.replace(/[/\\:*?"<>|\0]/g, '_')
.trim()
.slice(0, 255);
if (!cleaned || cleaned === '.' || cleaned === '..') return fallbackSafe;
return cleaned;
}
private sanitizeZipPath(rawPath: string, fallbackFilename: string): string {
const segments = rawPath
.replace(/\\/g, '/')
.split('/')
.filter(Boolean)
.map((segment) => this.sanitizeFilenameSegment(segment));
if (segments.length === 0) return this.sanitizeFilenameSegment(fallbackFilename);
return segments.join('/');
}
private withSuffix(path: string, suffix: number): string {
const slash = path.lastIndexOf('/');
const dir = slash >= 0 ? path.slice(0, slash + 1) : '';
const name = slash >= 0 ? path.slice(slash + 1) : path;
const dot = name.lastIndexOf('.');
if (dot > 0) return `${dir}${name.slice(0, dot)} (${suffix})${name.slice(dot)}`;
return `${dir}${name} (${suffix})`;
}
private makeUniqueZipPath(path: string, used: Set<string>): string {
if (!used.has(path)) {
used.add(path);
return path;
}
let suffix = 2;
let candidate = this.withSuffix(path, suffix);
while (used.has(candidate)) {
suffix += 1;
candidate = this.withSuffix(path, suffix);
}
used.add(candidate);
return candidate;
}
private resolveExportArchiveFilename(
bookCount: number,
orderedFiles: ExportCandidateFile[],
metadataByBookId: Map<number, Awaited<ReturnType<BookRepository['findPatternMetadataByBookIds']>>[number]>,
pattern: string,
): string {
if (bookCount !== 1) return 'books.zip';
const firstFile = orderedFiles[0];
if (!firstFile) return 'books.zip';
const originalStem = basename(firstFile.absolutePath, extname(firstFile.absolutePath)).trim() || 'book';
const fallback = this.sanitizeFilenameSegment(`${originalStem}.zip`, 'book.zip');
const meta = metadataByBookId.get(firstFile.bookId);
const tokens = {
...this.buildDownloadPatternTokens(firstFile.absolutePath, firstFile.format, false, meta),
originalFilename: meta?.title?.trim() || originalStem,
extension: 'zip',
};
const resolvedPath = resolveUploadPath(pattern || DEFAULT_DOWNLOAD_PATTERN, tokens, 'zip');
const resolvedName = resolvedPath?.split('/').filter(Boolean).pop() ?? null;
return this.sanitizeFilenameSegment(resolvedName ?? fallback, fallback);
}
private withDownloadFilenameLabel(filename: string, label: string, extension: string): string {
const dot = filename.lastIndexOf('.');
if (dot > 0) return `${filename.slice(0, dot)}${label}.${extension}`;
return `${filename}${label}.${extension}`;
}
private buildDownloadPatternTokens(
absolutePath: string,
format: string | null,
mediaOverlayAvailable: boolean,
meta?: Awaited<ReturnType<BookRepository['findPatternMetadataByBookIds']>>[number],
): Record<string, string> {
const pathExtension = extname(absolutePath).toLowerCase().slice(1);
const extension = pathExtension || (format && format !== 'unknown' ? format : 'bin');
const stem = basename(absolutePath, extname(absolutePath));
return buildPatternTokens({
metadata: meta ?? {},
authors: meta?.authors,
narrators: meta?.narrators,
originalStem: stem,
format: extension,
libraryName: meta?.libraryName,
mediaOverlayAvailable,
});
}
private async resolveDownloadFilenameForFile(file: {
bookId: number;
absolutePath: string;
format: string | null;
mediaOverlayAvailable: boolean;
}): Promise<string> {
const originalFilename = basename(file.absolutePath);
try {
const [pattern, metaRows] = await Promise.all([
this.appSettings.getDownloadPattern(),
this.bookRepo.findPatternMetadataByBookIds([file.bookId]),
]);
const tokens = this.buildDownloadPatternTokens(file.absolutePath, file.format, file.mediaOverlayAvailable, metaRows[0]);
const resolvedPath = resolveUploadPath(pattern || DEFAULT_DOWNLOAD_PATTERN, tokens, tokens.extension);
const resolvedName = resolvedPath?.split('/').filter(Boolean).pop() ?? null;
return this.sanitizeFilenameSegment(resolvedName ?? originalFilename, originalFilename);
} catch (err) {
const event = 'book.resolve_download_filename';
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${file.bookId} errorClass=${errorClass} error="${errorMessage}" - download filename pattern resolution failed`,
);
return this.sanitizeFilenameSegment(originalFilename, originalFilename);
}
}
async getFileInfo(
fileId: number,
user: RequestUser,
): Promise<{ path: string; size: number; format: string; bookId: number; originalFilename: string; mediaOverlayAvailable: boolean }> {
const file = await this.verifyFileAccess(fileId, user);
let size: number;
try {
({ size } = await stat(file.absolutePath));
} catch (err) {
if (this.isMissingFilesystemEntry(err)) {
throw new NotFoundException(`File ${fileId} not found on disk`);
}
throw err;
}
const originalFilename = basename(file.absolutePath);
return {
path: file.absolutePath,
size,
format: file.format ?? 'unknown',
bookId: file.bookId,
originalFilename,
mediaOverlayAvailable: file.mediaOverlayAvailable,
};
}
async resolveDownloadFilename(file: {
bookId: number;
absolutePath: string;
format: string | null;
mediaOverlayAvailable: boolean;
}): Promise<string> {
return this.resolveDownloadFilenameForFile(file);
}
async createAudiolessEpubDownload(
fileId: number,
user: RequestUser,
options: { linkKoreaderHash?: boolean } = {},
): Promise<{
path: string;
size: number;
filename: string;
bookId: number;
removedEntries: number;
sanitizedEntries: number;
koreaderHash: string;
cleanup: () => Promise<void>;
}> {
if (!this.audiolessEpubService) throw new InternalServerErrorException('Audioless EPUB service unavailable');
const file = await this.verifyFileAccess(fileId, user);
if (file.format?.toLowerCase() !== 'epub') throw new BadRequestException('Audioless export is only available for EPUB files');
const filename = this.withDownloadFilenameLabel(await this.resolveDownloadFilenameForFile(file), ' - KOReader', 'epub');
const tempDir = await mkdtemp(join(tmpdir(), 'bookorbit-koreader-epub-'));
const tempPath = join(tempDir, 'download.epub');
try {
const result = await this.audiolessEpubService.writeArchive(file.absolutePath, tempPath);
const [{ size }, koreaderHash] = await Promise.all([stat(tempPath), computeFileHash(tempPath)]);
if (options.linkKoreaderHash) {
await this.bookRepo.upsertKoreaderBookHashLink(user.id, koreaderHash, file.id);
}
return {
path: tempPath,
size,
filename,
bookId: file.bookId,
removedEntries: result.removedEntries,
sanitizedEntries: result.sanitizedEntries,
koreaderHash,
cleanup: () => rm(tempDir, { recursive: true, force: true }),
};
} catch (err) {
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
throw err;
}
}
async updateReadAloudSyncMode(bookId: number, mode: ReadAloudProgressSyncMode, user: RequestUser): Promise<BookDetailDto> {
await this.verifyBookAccess(bookId, user);
await this.bookRepo.upsertReadAloudSyncMode(user.id, bookId, mode);
return this.getDetail(bookId, user);
}
async renameFile(fileId: number, dto: UpdateBookFileDto, user: RequestUser): Promise<void> {
const event = 'book.rename_file';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] fileId=${fileId} userId=${user.id} - rename file started`);
try {
const file = await this.verifyFileAccess(fileId, user);
let newAbsolutePath = file.absolutePath;
if (dto.filename && dto.filename !== basename(file.absolutePath)) {
const safeFilename = basename(dto.filename);
if (
safeFilename !== dto.filename ||
safeFilename === '.' ||
safeFilename === '..' ||
safeFilename.includes('\\') ||
safeFilename.includes('\0') ||
Buffer.byteLength(safeFilename, 'utf8') > 255
) {
throw new BadRequestException('Filename is invalid');
}
newAbsolutePath = join(dirname(file.absolutePath), safeFilename);
if (newAbsolutePath !== file.absolutePath) {
try {
// codeql[js/path-injection] basename constrains the user-provided value to one validated filename segment.
await rename(file.absolutePath, newAbsolutePath);
} catch (err) {
throw new BadRequestException(`Failed to rename file on disk: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
await this.bookRepo.updateBookFile(fileId, {
absolutePath: newAbsolutePath !== file.absolutePath ? newAbsolutePath : undefined,
relPath: newAbsolutePath !== file.absolutePath ? relative(file.libraryFolderPath, newAbsolutePath) : undefined,
});
this.logger.log(`[${event}] [end] fileId=${fileId} durationMs=${Date.now() - startedAt} - rename file completed`);
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] fileId=${fileId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - rename file failed`,
);
throw err;
}
}
async deleteFile(fileId: number, user: RequestUser): Promise<void> {
const event = 'book.delete_file';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] fileId=${fileId} userId=${user.id} - delete file started`);
try {
const file = await this.verifyFileAccess(fileId, user);
try {
await rm(file.absolutePath, { force: true });
} catch {
throw new InternalServerErrorException('Failed to delete file from disk');
}
const book = await this.bookRepo.findBookBase(file.bookId);
const wasPrimary = book?.primaryFileId === fileId;
await this.bookRepo.deleteBookFile(fileId);
const allFiles = await this.bookRepo.findFilesForBook(file.bookId);
// deleteBookFile already removes it, but just in case
const remaining = allFiles.filter((f) => f.id !== fileId);
if (remaining.length === 0) {
// mark book as missing if no files left
await this.bookRepo.updateBookPrimaryFile(file.bookId, null);
} else if (wasPrimary) {
const contentFiles = remaining.filter((candidate) => candidate.role === 'content');
const library = await this.libraryService.findOne(file.libraryId);
const newPrimary = selectPrimaryFile(contentFiles, library.formatPriority ?? DEFAULT_FORMAT_PRIORITY, { allowZeroByteFallback: true });
await this.bookRepo.updateBookPrimaryFile(file.bookId, newPrimary?.id ?? null);
}
void this.coverReconciler?.enqueue([file.bookId], { filesChanged: true });
this.logger.log(`[${event}] [end] fileId=${fileId} durationMs=${Date.now() - startedAt} - delete file completed`);
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] fileId=${fileId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - delete file failed`,
);
throw err;
}
}
async searchAcrossLibraries(q: string, limit: number, user: RequestUser) {
const libs = await this.libraryService.findAll(user);
const libraryIds = libs.map((l) => l.id);
const contentFilters = this.isSuperuser(user) ? undefined : user.contentFilters;
return this.bookRepo.searchAcrossLibraries(libraryIds, q, limit, contentFilters);
}
async deleteBooks(bookIds: number[], user: RequestUser): Promise<BookDeletionAuditMeta> {
const event = 'book.delete_books';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] count=${bookIds.length} userId=${user.id} - delete books started`);
try {
if (bookIds.length === 0) {
this.logger.log(`[${event}] [end] count=0 durationMs=${Date.now() - startedAt} deletedBooks=0 deletedFiles=0 - delete books completed`);
return { total: 0, books: [], omitted: 0 };
}
const rows = await this.verifyLibraryAccessForBookIds(bookIds, user);
const rowIds = new Set(rows.map((row) => row.id));
const deletedBookIds = [...new Set(bookIds)].filter((bookId) => rowIds.has(bookId));
const auditedBookIds = deletedBookIds.slice(0, MAX_DELETION_AUDIT_BOOKS);
const auditRows = await this.bookRepo.findDeletionAuditBooksByIds(auditedBookIds);
const auditRowsById = new Map(auditRows.map((row) => [row.id, row]));
const auditBooks = auditedBookIds.flatMap((bookId) => {
const row = auditRowsById.get(bookId);
return row ? [row] : [];
});
const files = await this.bookRepo.findAllFilesByBookIds(bookIds);
await this.bookRepo.deleteByIdsAndInvalidateScanState(deletedBookIds);
const deleteTargets = [
...rows.map((row) => ({
path: join(this.appDataPath, 'covers', String(row.id)),
options: { recursive: true, force: true },
kind: 'coverDir' as const,
})),
...files.map((file) => ({ path: file.absolutePath, options: { force: true }, kind: 'bookFile' as const })),
];
const deleteResults = await Promise.allSettled(deleteTargets.map((target) => rm(target.path, target.options)));
let failedDeletes = 0;
for (let i = 0; i < deleteResults.length; i += 1) {
const result = deleteResults[i];
if (result?.status !== 'rejected') continue;
failedDeletes += 1;
const target = deleteTargets[i]!;
const reason = result.reason;
const errorClass = reason instanceof Error ? reason.name : 'Error';
const errorMessage = sanitizeLogValue(reason instanceof Error ? reason.message : String(reason));
const pathValue = sanitizeLogValue(target.path);
this.logger.warn(
`[${event}] [fail] userId=${user.id} path="${pathValue}" kind=${target.kind} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - delete books cleanup target failed`,
);
}
this.logger.log(
`[${event}] [end] count=${bookIds.length} durationMs=${Date.now() - startedAt} deletedBooks=${rows.length} deletedFiles=${files.length} failedDeletes=${failedDeletes} - delete books completed`,
);
return {
total: rows.length,
books: auditBooks,
omitted: Math.max(0, rows.length - auditBooks.length),
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] count=${bookIds.length} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - delete books failed`,
);
throw err;
}
}
async updateMetadata(
id: number,
dto: UpdateBookMetadataDto,
user: RequestUser,
options: { postSaveMode?: PostMetadataSaveMode } = {},
): Promise<MetadataSaveResult> {
const event = 'book.update_metadata';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] bookId=${id} userId=${user.id} - metadata update started`);
try {
await this.verifyBookAccess(id, user);
await this.bookMetadataLockService.assertManualUpdateAllowed(id, dto);
const { detail, scalarFieldCount, write, libraryAutoWriteEnabled } = await this.persistMetadataUpdate(id, dto, user, {
postSaveMode: options.postSaveMode ?? 'schedule',
});
this.logger.log(
`[${event}] [end] bookId=${id} durationMs=${Date.now() - startedAt} scalarFields=${scalarFieldCount} authorsUpdated=${dto.authors !== undefined} narratorsUpdated=${dto.audioMetadata?.narrators !== undefined} genresUpdated=${dto.genres !== undefined} tagsUpdated=${dto.tags !== undefined} audioMetadataUpdated=${dto.audioMetadata !== undefined} comicMetadataUpdated=${dto.comicMetadata !== undefined} customMetadataUpdated=${dto.customMetadata !== undefined} - metadata update completed`,
);
return { book: detail, write, libraryAutoWriteEnabled };
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata update failed`,
);
throw err;
}
}
async updateAddedAt(id: number, dto: UpdateBookAddedAtDto, user: RequestUser): Promise<BookDetailDto> {
await this.verifyBookAccess(id, user);
const timeZone = this.resolveUserTimeZone(user);
if (!isDateKey(dto.addedAt)) throw new BadRequestException('addedAt must be a valid date');
if (dto.addedAt > toDateKeyInTimeZone(new Date(), timeZone)) throw new BadRequestException('addedAt cannot be in the future');
await this.bookRepo.updateAddedAt(id, toTimeZoneStartOfDay(dto.addedAt, timeZone));
return this.getDetail(id, user);
}
async updateMetadataAndLocks(
id: number,
dto: UpdateBookMetadataAndLocksDto,
user: RequestUser,
options: { postSaveMode?: PostMetadataSaveMode } = {},
): Promise<MetadataSaveResult> {
const event = 'book.update_metadata_and_locks';
const startedAt = Date.now();
const metadata = dto.metadata ?? {};
this.logger.log(
`[${event}] [start] bookId=${id} userId=${user.id} metadataFields=${Object.keys(metadata).length} lockFields=${dto.lockedFields.length} - metadata and lock update started`,
);
try {
await this.verifyBookAccess(id, user);
// No lock guard here: this endpoint sets metadata and locks together from one explicit manual
// request, so a field carried in the payload is always an intentional edit (the editor disables
// locked inputs). Guarding on the final lock state would reject the legitimate unlock -> edit ->
// re-lock flow (issue #328). Automated writes are filtered separately via filterAutomatedBookUpdate.
const { detail, scalarFieldCount, normalizedLockedFields, write, libraryAutoWriteEnabled } = await this.persistMetadataUpdate(
id,
metadata,
user,
{
lockedFields: dto.lockedFields,
postSaveMode: options.postSaveMode ?? 'schedule',
},
);
this.logger.log(
`[${event}] [end] bookId=${id} durationMs=${Date.now() - startedAt} scalarFields=${scalarFieldCount} customMetadataUpdated=${metadata.customMetadata !== undefined} lockFields=${normalizedLockedFields?.length ?? 0} - metadata and lock update completed`,
);
return { book: detail, write, libraryAutoWriteEnabled };
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata and lock update failed`,
);
throw err;
}
}
private async persistMetadataUpdate(
id: number,
dto: UpdateBookMetadataDto,
user: RequestUser,
options: { lockedFields?: readonly string[]; postSaveMode?: PostMetadataSaveMode } = {},
): Promise<{
detail: BookDetailDto;
scalarFieldCount: number;
normalizedLockedFields?: BookMetadataLockField[];
write: WriteResult | null;
libraryAutoWriteEnabled: boolean;
}> {
const scalarFields: Parameters<BookRepository['updateMetadataFields']>[1] = {};
if (dto.title !== undefined) scalarFields.title = dto.title ?? null;
if (dto.subtitle !== undefined) scalarFields.subtitle = dto.subtitle ?? null;
if (dto.description !== undefined) scalarFields.description = dto.description ?? null;
if (dto.publisher !== undefined) scalarFields.publisher = normalizeMetadataText(dto.publisher);
if (dto.publishedDate !== undefined) {
const publishedDate = this.normalizeUpdatePublishedDate(dto.publishedDate);
scalarFields.publishedDate = publishedDate;
if (publishedDate !== null) {
scalarFields.publishedYear = publishedYearFromDateKey(publishedDate);
} else if (dto.publishedYear !== undefined) {
scalarFields.publishedYear = dto.publishedYear ?? null;
}
} else if (dto.publishedYear !== undefined) {
scalarFields.publishedDate = null;
scalarFields.publishedYear = dto.publishedYear ?? null;
}
if (dto.language !== undefined) scalarFields.language = dto.language ?? null;
if (dto.pageCount !== undefined) scalarFields.pageCount = dto.pageCount ?? null;
if (dto.seriesMemberships === undefined) {
if (dto.seriesName !== undefined) scalarFields.seriesName = normalizeMetadataText(dto.seriesName);
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
}
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null;
if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? null;
if (dto.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null;
if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
if (dto.hardcoverId !== undefined) scalarFields.hardcoverId = dto.hardcoverId ?? null;
if (dto.hardcoverEditionId !== undefined) scalarFields.hardcoverEditionId = dto.hardcoverEditionId ?? null;
if (dto.openLibraryId !== undefined) scalarFields.openLibraryId = dto.openLibraryId ?? null;
if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null;
if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null;
if (dto.librofmId !== undefined) scalarFields.librofmId = dto.librofmId ?? null;
if (dto.koboId !== undefined) scalarFields.koboId = dto.koboId ?? null;
if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null;
if (dto.ranobedbId !== undefined) scalarFields.ranobedbId = dto.ranobedbId ?? null;
if (dto.lubimyczytacId !== undefined) scalarFields.lubimyczytacId = dto.lubimyczytacId ?? null;
if (dto.aladinId !== undefined) scalarFields.aladinId = dto.aladinId ?? null;
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
if (dto.audioMetadata) {
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
if (dto.audioMetadata.abridged !== undefined) scalarFields.abridged = dto.audioMetadata.abridged ?? false;
if (dto.audioMetadata.chapters !== undefined) scalarFields.chapters = dto.audioMetadata.chapters ?? null;
}
const communityRatings = this.normalizeCommunityRatingInputs(dto.communityRatings);
const scalarFieldCount = Object.keys(scalarFields).length;
const hasMetadataUpdate = Object.keys(dto).length > 0;
const customMetadataLibraryId = dto.customMetadata !== undefined ? await this.bookRepo.findLibraryIdByBookId(id) : null;
if (dto.customMetadata !== undefined && customMetadataLibraryId === null) throw new NotFoundException(`Book ${id} not found`);
let replacedAuthorIds: number[] = [];
let normalizedLockedFields: BookMetadataLockField[] | undefined;
let write: WriteResult | null = null;
let libraryAutoWriteEnabled = false;
await this.bookRepo.withTransaction(async (tx) => {
if (scalarFieldCount > 0) {
scalarFields.updatedAt = new Date();
await this.bookRepo.updateMetadataFields(id, scalarFields, tx);
}
this.throwIfMetadataUpdateFailpoint('afterScalarUpdate');
if (communityRatings !== undefined) {
await this.bookRepo.replaceCommunityRatings(id, communityRatings, tx);
}
if (dto.seriesMemberships !== undefined) {
await this.seriesMemberships?.replaceForBook(id, dto.seriesMemberships, tx);
// After replaceForBook, because it is what creates any series row the editor named.
for (const membership of dto.seriesMemberships ?? []) {
if (membership.expectedBookCount === undefined) continue;
await this.seriesExpectedCount?.setManual(membership.seriesName, membership.expectedBookCount ?? null, tx);
}
}
this.throwIfMetadataUpdateFailpoint('afterSeriesMembershipsReplace');
if (dto.comicMetadata) {
await this.comicMetadataService.upsert(id, dto.comicMetadata, tx);
}
this.throwIfMetadataUpdateFailpoint('afterComicMetadataUpsert');
if (dto.authors !== undefined) {
replacedAuthorIds = await this.metadataService.replaceAuthors(
id,
dto.authors.map((name) => ({ name, sortName: null })),
{ executor: tx, emitEvent: false },
);
}
this.throwIfMetadataUpdateFailpoint('afterAuthorsReplace');
if (dto.audioMetadata?.narrators !== undefined) {
await this.narratorService.replaceForBook(id, dto.audioMetadata.narrators, { executor: tx });
}
this.throwIfMetadataUpdateFailpoint('afterNarratorsReplace');
if (dto.genres !== undefined) {
await this.metadataService.replaceGenres(id, dto.genres, { executor: tx });
}
this.throwIfMetadataUpdateFailpoint('afterGenresReplace');
if (dto.tags !== undefined) {
await this.metadataService.replaceTags(id, dto.tags, { executor: tx });
}
this.throwIfMetadataUpdateFailpoint('afterTagsReplace');
if (dto.customMetadata !== undefined) {
await this.customMetadataService.updateBookValues(id, customMetadataLibraryId!, dto.customMetadata, tx);
}
if (options.lockedFields !== undefined) {
normalizedLockedFields = await this.bookMetadataLockService.replaceLockedFields(id, options.lockedFields, tx);
}
this.throwIfMetadataUpdateFailpoint('beforeTransactionCommit');
});
if (dto.authors !== undefined) {
this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds);
}
if (dto.rating !== undefined) {
const rating = dto.rating ?? null;
await this.bookRepo.bulkSetRating([id], rating, user.id);
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: [id],
rating,
});
}
// Clearing is intentionally not propagated, so it can't disrupt an unrelated active sync.
if (dto.hardcoverEditionId != null) {
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, {
userId: user.id,
bookId: id,
hardcoverEditionId: dto.hardcoverEditionId,
});
}
if (hasMetadataUpdate) {
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
const hasRenameRelevantField =
dto.seriesMemberships !== undefined ||
Array.from(RENAME_RELEVANT_FIELDS).some((field) => (dto as Record<string, unknown>)[field] !== undefined);
const postSaveMode = options.postSaveMode ?? 'schedule';
if (postSaveMode === 'sync') {
const settingsResult = await this.findLibraryWriteSettingsAfterSave(id);
const settings = settingsResult.settings;
write = settingsResult.writeFailure;
libraryAutoWriteEnabled = settings?.fileWriteEnabled ?? false;
this.fileWriteService?.cancelPendingWrite(id);
this.fileRenameService?.cancelPendingRename(id);
if (libraryAutoWriteEnabled) {
write = await this.writeMetadataToFileAfterSave(id, user);
}
const syncWriteFailed = write?.status === 'failed';
if (hasRenameRelevantField && settings?.fileRenameEnabled && !syncWriteFailed) {
await this.renameFileAfterSave(id, user);
}
} else {
this.fileWriteService?.scheduleWrite(id, 'auto', user.id);
if (hasRenameRelevantField) {
this.fileRenameService?.scheduleRename(id, user.id);
}
}
await this.scoreService.calculateAndSave(id);
}
const detail = await this.getDetail(id, user);
return { detail, scalarFieldCount, normalizedLockedFields, write, libraryAutoWriteEnabled };
}
private async findLibraryWriteSettingsAfterSave(bookId: number): Promise<LibraryWriteSettingsLookupResult> {
const startedAt = Date.now();
try {
return {
settings: (await this.fileWriteService?.findLibraryWriteSettingsForBook(bookId)) ?? null,
writeFailure: null,
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[book.update_metadata_file_write_settings] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - post-save file write settings lookup failed`,
);
return {
settings: null,
writeFailure: {
status: 'failed',
fieldsWritten: [],
durationMs: Date.now() - startedAt,
reason: 'file write settings unavailable',
},
};
}
}
private async writeMetadataToFileAfterSave(bookId: number, user: RequestUser): Promise<WriteResult> {
try {
return (
(await this.fileWriteService?.writeToFile(bookId, 'sync', user.id, false, false, true)) ?? {
status: 'skipped',
fieldsWritten: [],
durationMs: 0,
reason: 'file write service unavailable',
}
);
} catch (err) {
return {
status: 'failed',
fieldsWritten: [],
durationMs: 0,
reason: err instanceof Error ? err.message : String(err),
};
}
}
private async renameFileAfterSave(bookId: number, user: RequestUser): Promise<void> {
const startedAt = Date.now();
try {
await this.fileRenameService?.performRename(bookId, user.id, false, true);
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[book.update_metadata_rename] [fail] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - post-save file rename failed`,
);
}
}
async updateMetadataLocks(id: number, lockedFields: string[], user: RequestUser): Promise<BookDetailDto> {
const event = 'book.update_metadata_locks';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] bookId=${id} userId=${user.id} - metadata lock update started`);
try {
await this.verifyBookAccess(id, user);
const normalizedLockedFields = await this.bookMetadataLockService.replaceLockedFields(id, lockedFields);
const detail = await this.getDetail(id, user);
this.logger.log(
`[${event}] [end] bookId=${id} durationMs=${Date.now() - startedAt} lockedFields=${normalizedLockedFields.length} - metadata lock update completed`,
);
return detail;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata lock update failed`,
);
throw err;
}
}
setMetadataUpdateFailpointForTests(failpoint: MetadataUpdateFailpoint | null): void {
if (failpoint !== null && !METADATA_UPDATE_FAILPOINTS.includes(failpoint)) {
throw new InternalServerErrorException(`Unknown metadata update failpoint: ${failpoint}`);
}
this.metadataUpdateFailpoint = failpoint;
}
clearMetadataUpdateFailpointForTests(): void {
this.metadataUpdateFailpoint = null;
}
private throwIfMetadataUpdateFailpoint(stage: MetadataUpdateFailpoint): void {
if (this.metadataUpdateFailpoint !== stage) return;
this.metadataUpdateFailpoint = null;
throw new InternalServerErrorException(`Metadata update failpoint triggered: ${stage}`);
}
async embedAll(): Promise<{ queued: number }> {
const event = 'book.embed_all';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] - embed all started`);
try {
if (!this.embedder) {
this.logger.log(`[${event}] [end] durationMs=${Date.now() - startedAt} queued=0 runStarted=false - embed all completed`);
return { queued: 0 };
}
if (this.embeddingRun) {
this.logger.log(`[${event}] [end] durationMs=${Date.now() - startedAt} queued=0 runStarted=false alreadyRunning=true - embed all completed`);
return { queued: 0 };
}
const bookIds = await this.bookRepo.findAllIds();
this.embeddingRun = this.runEmbeddings(bookIds).finally(() => {
this.embeddingRun = null;
});
this.logger.log(`[${event}] [end] durationMs=${Date.now() - startedAt} queued=${bookIds.length} runStarted=true - embed all completed`);
return { queued: bookIds.length };
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(`[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - embed all failed`);
throw err;
}
}
private async runEmbeddings(bookIds: number[]): Promise<void> {
const event = 'book.run_embeddings';
const startedAt = Date.now();
const batch = 10;
this.logger.log(`[${event}] [start] totalBooks=${bookIds.length} batchSize=${batch} - embeddings run started`);
if (!this.embedder) {
this.logger.log(
`[${event}] [end] totalBooks=${bookIds.length} durationMs=${Date.now() - startedAt} processed=0 failed=0 - embeddings run completed`,
);
return;
}
let processed = 0;
let failed = 0;
for (let i = 0; i < bookIds.length; i += batch) {
const currentBatch = bookIds.slice(i, i + batch);
const results = await Promise.allSettled(currentBatch.map((id) => this.embedder!.embedBook(id)));
for (let batchIndex = 0; batchIndex < results.length; batchIndex += 1) {
const result = results[batchIndex]!;
const bookId = currentBatch[batchIndex]!;
if (result.status === 'fulfilled') {
processed += 1;
continue;
}
failed += 1;
const reason = result.reason;
const errorClass = reason instanceof Error ? reason.name : 'Error';
const errorMessage = sanitizeLogValue(reason instanceof Error ? reason.message : String(reason));
this.logger.warn(
`[${event}] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - embedding run item failed`,
);
}
}
this.logger.log(
`[${event}] [end] totalBooks=${bookIds.length} durationMs=${Date.now() - startedAt} processed=${processed} failed=${failed} - embeddings run completed`,
);
}
async getProgress(userId: number, fileId: number, user: RequestUser) {
await this.verifyFileAccess(fileId, user);
return this.bookRepo.findProgress(userId, fileId);
}
async getBookProgress(userId: number, bookId: number, user: RequestUser) {
await this.verifyBookAccess(bookId, user);
const rows = await this.bookRepo.findProgressByBook(userId, bookId);
return rows.map((row) => ({
fileId: row.fileId,
cfi: row.cfi ?? null,
pageNumber: row.pageNumber ?? null,
positionSeconds: row.positionSeconds ?? null,
mediaOverlayFragment: row.mediaOverlayFragment ?? null,
mediaOverlaySectionIndex: row.mediaOverlaySectionIndex ?? null,
percentage: row.percentage ?? 0,
koboLocationSource: row.koboLocationSource ?? null,
koboLocationType: row.koboLocationType ?? null,
koboLocationValue: row.koboLocationValue ?? null,
koboContentSourceProgressPercent: row.koboContentSourceProgressPercent ?? null,
koreaderProgress: row.koreaderProgress ?? null,
updatedAt: row.updatedAt ?? null,
}));
}
async getAudioProgress(userId: number, bookId: number, user: RequestUser) {
await this.verifyBookAccess(bookId, user);
return this.bookRepo.findAudioProgress(userId, bookId);
}
async saveAudioProgress(userId: number, bookId: number, dto: UpsertAudioProgressDto, user: RequestUser) {
const libraryId = await this.bookRepo.findLibraryIdByBookId(bookId);
if (libraryId === null) throw new NotFoundException(`Book ${bookId} not found`);
await this.libraryService.verifyUserAccess(userId, libraryId, this.isSuperuser(user));
const currentFile = await this.verifyFileAccess(dto.currentFileId, user);
if (currentFile.bookId !== bookId) {
throw new BadRequestException(`currentFileId ${dto.currentFileId} does not belong to book ${bookId}`);
}
const previous = await this.bookRepo.findAudioProgress(userId, bookId);
const saved = await this.bookRepo.upsertAudioProgress(userId, bookId, dto.currentFileId, dto.positionSeconds, dto.percentage);
if (!saved) return;
await this.audiobookEbookProgressSync?.syncFromAudioProgress({
userId,
bookId,
currentFileId: dto.currentFileId,
positionSeconds: dto.positionSeconds,
percentage: dto.percentage,
syncKobo: this.hasPermission(user, Permission.KoboSync),
sourceUpdatedAt: saved.capturedAt,
});
const strongRereadEvidence = previous != null && previous.percentage - dto.percentage >= 10;
await this.autoUpdateReadStatusForProgress(userId, { bookId, libraryId }, dto.percentage, {
origin: 'bookorbit',
timeZone: this.resolveUserTimeZone(user),
strongRereadEvidence,
});
}
async syncEbookProgressForAudiobookPlayback(
user: RequestUser,
bookId: number,
currentFileId: number,
positionSeconds: number,
percentage: number,
sourceUpdatedAt: Date,
): Promise<void> {
await this.audiobookEbookProgressSync?.syncFromAudioProgress({
userId: user.id,
bookId,
currentFileId,
positionSeconds,
percentage,
syncKobo: this.hasPermission(user, Permission.KoboSync),
sourceUpdatedAt,
});
}
async autoUpdateReadStatusForProgress(
userId: number,
file: ProgressStatusFileContext,
percentage: number,
activity: AutoReadingActivity = {},
): Promise<void> {
const startedAt = Date.now();
try {
const library = await this.libraryService.findOne(file.libraryId);
await this.userBookStatusService.autoUpdate(
userId,
file.bookId,
percentage,
library.readingThreshold,
library.markAsFinishedPercentComplete,
activity,
);
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn(
`[book.progress_status_update] [fail] userId=${userId} bookId=${file.bookId} libraryId=${file.libraryId} durationMs=${Date.now() - startedAt} errorClass=${err.constructor.name} error="${sanitizeLogValue(err.message)}" - auto status update failed`,
);
}
}
/**
* Advances the Kobo bookmark for progress reported by non-web readers (KOReader).
* Percent-only: the precise KoboSpan Location is computed at delivery time by the
* Kobo reading-state pull path.
*/
async syncKoboReadingStateForExternalProgress(userId: number, fileId: number, percentage: number): Promise<void> {
if (!(await this.bookRepo.isKoboTwoWayProgressSyncEnabled(userId))) return;
await this.bookRepo.syncKoboReadingStateFromProgress(userId, fileId, percentage, null, null, null, null);
}
/**
* Where a write leaves the file's text position.
*
* A text write speaks for it outright, backwards included, because turning back is reading. A
* narration write only ever carries it forward: the narration marker and the page a reader
* reached are different places, and a read-along that resumes behind the page must not drag the
* page back to it. The audiobook-to-ebook sync deliberately does not come through here, since
* moving the text position is that feature's whole purpose and it has its own setting.
*/
private resolveTextPosition(dto: SaveProgressDto, previous: { percentage: number; cfi: string | null; pageNumber: number | null } | null) {
if (dto.source !== 'narration') {
return { percentage: dto.percentage, cfi: dto.cfi ?? null, pageNumber: dto.pageNumber ?? null, moved: true };
}
const stored = previous?.percentage ?? 0;
if (dto.percentage > stored) {
return { percentage: dto.percentage, cfi: dto.cfi ?? previous?.cfi ?? null, pageNumber: previous?.pageNumber ?? null, moved: true };
}
return { percentage: stored, cfi: previous?.cfi ?? null, pageNumber: previous?.pageNumber ?? null, moved: false };
}
async saveProgress(userId: number, fileId: number, dto: SaveProgressDto, user: RequestUser) {
const file = await this.verifyFileAccess(fileId, user);
const previous = await this.bookRepo.findProgress(userId, fileId);
const now = new Date();
const text = this.resolveTextPosition(dto, previous ?? null);
await this.bookRepo.upsertProgress(
userId,
fileId,
text.cfi,
text.pageNumber,
text.percentage,
dto.positionSeconds ?? null,
dto.mediaOverlayFragment ?? null,
dto.mediaOverlaySectionIndex ?? null,
dto.koboLocationSource ?? null,
dto.koboLocationType ?? null,
dto.koboLocationValue ?? null,
dto.koboContentSourceProgressPercent ?? null,
dto.koreaderProgress ?? null,
dto.source === 'narration' ? { percentage: dto.percentage, updatedAt: now } : null,
text.moved ? now : null,
);
// Everything downstream reads the position the file now holds, not the one the client sent.
// A narration write that did not move the text position must not move a Kobo bookmark, an
// audiobook position, or a read status either.
if (file.format === 'epub' && this.hasPermission(user, Permission.KoboSync) && (await this.bookRepo.isKoboTwoWayProgressSyncEnabled(userId))) {
await this.bookRepo.syncKoboReadingStateFromProgress(
userId,
fileId,
text.percentage,
dto.koboLocationSource ?? null,
dto.koboLocationType ?? null,
dto.koboLocationValue ?? null,
dto.koboContentSourceProgressPercent ?? null,
);
}
await this.audiobookEbookProgressSync?.syncFromEbookProgress({
userId,
bookId: file.bookId,
bookFileId: fileId,
percentage: text.percentage,
cfi: text.cfi,
koreaderProgress: dto.koreaderProgress ?? null,
positionSeconds: dto.positionSeconds ?? null,
mediaOverlayFragment: dto.mediaOverlayFragment ?? null,
mediaOverlaySectionIndex: dto.mediaOverlaySectionIndex ?? null,
sourceUpdatedAt: now,
syncSiblingEpubs: text.moved,
});
const strongRereadEvidence = previous != null && previous.percentage - text.percentage >= 10;
await this.autoUpdateReadStatusForProgress(userId, file, text.percentage, {
origin: 'bookorbit',
timeZone: this.resolveUserTimeZone(user),
strongRereadEvidence,
});
}
async syncAudioProgressForExternalEbookProgress(
userId: number,
bookId: number,
bookFileId: number,
percentage: number,
locators: {
cfi?: string | null;
koreaderProgress?: string | null;
positionSeconds?: number | null;
mediaOverlayFragment?: string | null;
mediaOverlaySectionIndex?: number | null;
sourceUpdatedAt?: Date;
syncSiblingEpubs?: boolean;
} = {},
): Promise<void> {
await this.audiobookEbookProgressSync?.syncFromEbookProgress({
userId,
bookId,
bookFileId,
percentage,
cfi: locators.cfi ?? null,
koreaderProgress: locators.koreaderProgress ?? null,
positionSeconds: locators.positionSeconds ?? null,
mediaOverlayFragment: locators.mediaOverlayFragment ?? null,
mediaOverlaySectionIndex: locators.mediaOverlaySectionIndex ?? null,
sourceUpdatedAt: locators.sourceUpdatedAt,
syncSiblingEpubs: locators.syncSiblingEpubs,
});
}
async clearFileProgress(userId: number, fileId: number, user: RequestUser): Promise<void> {
await this.verifyFileAccess(fileId, user);
await this.bookRepo.clearFileProgress(userId, fileId);
}
async clearBookProgressForReread(userId: number, bookId: number, user: RequestUser): Promise<void> {
await this.verifyBookAccess(bookId, user);
await this.bookRepo.clearBookProgress(userId, bookId);
}
async setReadStatus(bookId: number, dto: SetStatusDto, user: RequestUser): Promise<UserBookStatus> {
await this.verifyBookAccess(bookId, user);
const hasStatus = dto.status !== undefined;
const hasStartedAt = dto.startedAt !== undefined;
const hasFinishedAt = dto.finishedAt !== undefined;
if (!hasStatus && !hasStartedAt && !hasFinishedAt) {
throw new BadRequestException('At least one of status, startedAt, or finishedAt is required');
}
const timeZone = this.resolveUserTimeZone(user);
const patch: { status?: ReadStatus; startedAt?: Date | null; finishedAt?: Date | null } = {};
let startedKey: string | null | undefined;
if (hasStartedAt) {
if (dto.startedAt === null) {
patch.startedAt = null;
startedKey = null;
} else {
const normalized = this.parseManualReadDateInput(dto.startedAt, 'startedAt', timeZone);
patch.startedAt = normalized.date;
startedKey = normalized.dateKey;
}
}
let finishedKey: string | null | undefined;
if (hasFinishedAt) {
if (dto.finishedAt === null) {
patch.finishedAt = null;
finishedKey = null;
} else {
const normalized = this.parseManualReadDateInput(dto.finishedAt, 'finishedAt', timeZone);
patch.finishedAt = normalized.date;
finishedKey = normalized.dateKey;
}
}
if (hasStatus && dto.status !== undefined) {
patch.status = dto.status;
}
const dateKeys = {
...(startedKey ? { startedOn: startedKey } : {}),
...(finishedKey ? { endedOn: finishedKey } : {}),
};
const updated =
Object.keys(dateKeys).length > 0
? await this.userBookStatusService.updateManual(user.id, bookId, patch, dateKeys)
: await this.userBookStatusService.updateManual(user.id, bookId, patch);
return this.toDateOnlyReadStatus(updated, timeZone);
}
private resolveUserTimeZone(user: RequestUser): string {
const timezoneValue = (user.settings as { timezone?: unknown } | undefined)?.timezone;
return resolveTimeZone(timezoneValue, 'UTC');
}
private parseManualReadDateInput(
value: string | null | undefined,
field: 'startedAt' | 'finishedAt',
timeZone: string,
): { dateKey: string; date: Date } {
if (typeof value !== 'string') {
throw new BadRequestException(`${field} must be a date string or null`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new BadRequestException(`${field} must be a non-empty date string`);
}
const dateKey = this.normalizeInputToDateKey(trimmed, field, timeZone);
const todayKey = toDateKeyInTimeZone(new Date(), timeZone);
if (dateKey > todayKey) {
throw new BadRequestException({
message: `${field} cannot be in the future`,
errorCode: field === 'startedAt' ? READING_DATE_ERROR_CODES.startedInFuture : READING_DATE_ERROR_CODES.finishedInFuture,
});
}
return {
dateKey,
date: toTimeZoneStartOfDay(dateKey, timeZone),
};
}
private normalizeInputToDateKey(value: string, field: 'startedAt' | 'finishedAt', timeZone: string): string {
if (isDateKey(value)) {
return value;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException(`${field} must be a valid date`);
}
return toDateKeyInTimeZone(parsed, timeZone);
}
private normalizeStoredStatusDateKey(value: string | null, timeZone: string): string | null {
if (!value) return null;
if (isDateKey(value)) return value;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
return toDateKeyInTimeZone(parsed, timeZone);
}
private toDateOnlyReadStatus(status: UserBookStatus, timeZone: string): UserBookStatus {
return {
...status,
startedAt: this.normalizeStoredStatusDateKey(status.startedAt, timeZone),
finishedAt: this.normalizeStoredStatusDateKey(status.finishedAt, timeZone),
};
}
async bulkSetStatus(bookIds: number[], status: ReadStatus, user: RequestUser): Promise<void> {
const event = 'book.bulk.set_status';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} status=${status} - bulk set status started`);
await this.verifyLibraryAccessForBookIds(bookIds, user);
await this.userBookStatusService.bulkSetManual(user.id, bookIds, status);
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} status=${status} durationMs=${Date.now() - startedAt} - bulk set status completed`,
);
}
async bulkSetRating(bookIds: number[], rating: number | null, user: RequestUser): Promise<void> {
const event = 'book.bulk.set_rating';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} - bulk set rating started`);
await this.verifyLibraryAccessForBookIds(bookIds, user);
const lockedIds = await this.bookMetadataLockService.getBookIdsWithLockedField(bookIds, 'rating');
const updatableIds = bookIds.filter((bookId) => !lockedIds.has(bookId));
if (updatableIds.length > 0) {
await this.bookRepo.bulkSetRating(updatableIds, rating, user.id);
await this.triggerPostMetadataUpdateEffects(updatableIds, user.id);
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: updatableIds,
rating,
});
}
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} updated=${updatableIds.length} skippedLocked=${lockedIds.size} durationMs=${Date.now() - startedAt} - bulk set rating completed`,
);
}
async bulkSetMetadata(bookIds: number[], field: BulkMetadataField, value: string | number | string[] | null, user: RequestUser): Promise<void> {
const event = 'book.bulk.set_metadata';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} field=${field} - bulk set metadata started`);
await this.verifyLibraryAccessForBookIds(bookIds, user);
const lockField = BULK_METADATA_LOCK_FIELD_BY_FIELD[field];
const lockedIds = await this.bookMetadataLockService.getBookIdsWithLockedField(bookIds, lockField);
const updatableIds = bookIds.filter((bookId) => !lockedIds.has(bookId));
if (updatableIds.length > 0) {
const normalizeListValue = (raw: string | number | string[] | null, collapseWhitespace = false): string[] => {
const values = Array.isArray(raw) ? raw.map(String) : raw === null ? [] : [String(raw)];
return collapseWhitespace ? this.normalizePersonNameListValues(values) : this.normalizeListValues(values);
};
if (field === 'seriesName' || field === 'publisher' || field === 'language' || field === 'publishedYear') {
if (field === 'publishedYear') {
const year = value === null ? null : typeof value === 'number' ? value : Number(value);
if (year !== null && (!Number.isFinite(year) || !Number.isInteger(year))) {
throw new BadRequestException('Invalid publishedYear value');
}
await this.bookRepo.bulkUpdateMetadataFields(updatableIds, { publishedDate: null, publishedYear: year, updatedAt: new Date() });
} else {
const textValue =
field === 'language'
? value === null
? null
: String(value).trim() || null
: value === null
? null
: normalizeMetadataText(String(value));
await this.bookRepo.bulkUpdateMetadataFields(updatableIds, { [field]: textValue?.length ? textValue : null, updatedAt: new Date() });
}
} else {
const names = normalizeListValue(value, field === 'authors' || field === 'narrators');
await this.bookRepo.withTransaction(async (tx) => {
for (const bookId of updatableIds) {
if (field === 'authors') {
await this.metadataService.replaceAuthors(
bookId,
names.map((name) => ({ name, sortName: null })),
{ executor: tx },
);
continue;
}
if (field === 'genres') {
await this.metadataService.replaceGenres(bookId, names, { executor: tx });
continue;
}
if (field === 'tags') {
await this.metadataService.replaceTags(bookId, names, { executor: tx });
continue;
}
await this.narratorService.replaceForBook(bookId, names, { executor: tx });
}
});
}
await this.triggerPostMetadataUpdateEffects(updatableIds, user.id);
}
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} field=${field} updated=${updatableIds.length} skippedLocked=${lockedIds.size} durationMs=${Date.now() - startedAt} - bulk set metadata completed`,
);
}
async bulkUpdateTags(bookIds: number[], mode: 'add' | 'remove' | 'replace', tags: string[], user: RequestUser): Promise<void> {
const event = 'book.bulk.update_tags';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} mode=${mode} tagCount=${tags.length} - bulk update tags started`);
await this.verifyLibraryAccessForBookIds(bookIds, user);
await this.bookRepo.withTransaction(async (tx) => {
if (mode === 'replace') {
for (const bookId of bookIds) {
await this.metadataService.replaceTags(bookId, tags, { executor: tx });
}
return;
}
const currentTagsMap = await this.bookRepo.findTagsByBookIds(bookIds, tx);
for (const bookId of bookIds) {
const current = currentTagsMap.get(bookId) ?? [];
const next = mode === 'add' ? [...new Set([...current, ...tags])] : current.filter((t) => !tags.includes(t));
await this.metadataService.replaceTags(bookId, next, { executor: tx });
}
});
await this.triggerPostMetadataUpdateEffects(bookIds, user.id);
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} mode=${mode} tagCount=${tags.length} durationMs=${Date.now() - startedAt} - bulk update tags completed`,
);
}
async bulkSetMetadataLock(bookIds: number[], locked: boolean, user: RequestUser): Promise<void> {
const event = 'book.bulk.set_metadata_lock';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} locked=${locked} - bulk set metadata lock started`);
await this.verifyLibraryAccessForBookIds(bookIds, user);
const fields: BookMetadataLockField[] = locked ? [...BOOK_METADATA_LOCK_FIELDS] : [];
await this.bookMetadataLockService.bulkReplaceLockedFields(bookIds, fields);
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} locked=${locked} durationMs=${Date.now() - startedAt} - bulk set metadata lock completed`,
);
}
async bulkEditMetadata(bookIds: number[], fields: BulkEditFieldsDto, user: RequestUser): Promise<BulkEditMetadataResult> {
const event = 'book.bulk.edit_metadata';
const startedAt = Date.now();
const fieldNames = Object.keys(fields).filter((k) => (fields as unknown as Record<string, unknown>)[k] !== undefined);
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} fields=${fieldNames.join(',')} - bulk edit metadata started`);
if (!fields.hasAtLeastOneField()) {
throw new BadRequestException('fields must contain at least one editable field');
}
if (!fields.hasValidArrayValues()) {
throw new BadRequestException('array fields with add or remove mode must have a non-empty values array');
}
await this.verifyLibraryAccessForBookIds(bookIds, user);
const locksMap = await this.bookMetadataLockService.getLockedFieldsMap(bookIds);
const fieldResults: Record<string, BulkEditFieldResult> = {};
const allUpdatedBookIds = new Set<number>();
const getUpdatableIds = (lockField: BookMetadataLockField): number[] => {
return bookIds.filter((id) => {
const locked = locksMap.get(id) ?? [];
return !locked.includes(lockField);
});
};
const getSeriesUpdatableIds = (): number[] => {
return bookIds.filter((id) => {
const locked = locksMap.get(id) ?? [];
return !locked.includes('seriesName') && !locked.includes('seriesIndex');
});
};
const recordResult = (fieldName: string, updatableIds: number[]) => {
const skippedLocked = bookIds.length - updatableIds.length;
fieldResults[fieldName] = { updated: updatableIds.length, skippedLocked };
for (const id of updatableIds) allUpdatedBookIds.add(id);
};
try {
await this.bookRepo.withTransaction(async (tx) => {
for (const fieldName of ['seriesName', 'publisher', 'language'] as const) {
if (!fields[fieldName]) continue;
const ids = getUpdatableIds(BULK_METADATA_LOCK_FIELD_BY_FIELD[fieldName]);
recordResult(fieldName, ids);
if (ids.length === 0) continue;
const val = fields[fieldName].value;
const textValue = fieldName === 'language' ? (val === null ? null : String(val).trim() || null) : normalizeMetadataText(val);
await this.bookRepo.bulkUpdateMetadataFields(ids, { [fieldName]: textValue, updatedAt: new Date() }, tx);
}
if (fields.seriesMemberships !== undefined) {
const ids = getSeriesUpdatableIds();
recordResult('seriesMemberships', ids);
for (const bookId of ids) {
await this.seriesMemberships?.replaceForBook(bookId, fields.seriesMemberships, tx);
}
}
if (fields.publishedYear) {
const ids = getUpdatableIds('publishedYear');
recordResult('publishedYear', ids);
if (ids.length > 0) {
const val = fields.publishedYear.value;
if (val !== null && (!Number.isFinite(val) || !Number.isInteger(val))) {
throw new BadRequestException('Invalid publishedYear value');
}
await this.bookRepo.bulkUpdateMetadataFields(ids, { publishedDate: null, publishedYear: val, updatedAt: new Date() }, tx);
}
}
if (fields.authors) {
const ids = getUpdatableIds('authors');
recordResult('authors', ids);
if (ids.length > 0) {
const names = this.normalizePersonNameListValues(fields.authors.values);
if (fields.authors.mode === 'replace') {
for (const bookId of ids) {
await this.metadataService.replaceAuthors(
bookId,
names.map((name) => ({ name, sortName: null })),
{ executor: tx },
);
}
} else {
const currentMap = await this.bookRepo.findAuthorsByBookIds(ids, tx);
for (const bookId of ids) {
const current = currentMap.get(bookId) ?? [];
const merged =
fields.authors!.mode === 'add'
? this.normalizePersonNameListValues([...current, ...names])
: this.removePersonNameValues(current, names);
await this.metadataService.replaceAuthors(
bookId,
merged.map((name) => ({ name, sortName: null })),
{ executor: tx },
);
}
}
}
}
if (fields.genres) {
const ids = getUpdatableIds('genres');
recordResult('genres', ids);
if (ids.length > 0) {
const names = this.normalizeListValues(fields.genres.values);
if (fields.genres.mode === 'replace') {
for (const bookId of ids) {
await this.metadataService.replaceGenres(bookId, names, { executor: tx });
}
} else {
const currentMap = await this.bookRepo.findGenresByBookIds(ids, tx);
for (const bookId of ids) {
const current = currentMap.get(bookId) ?? [];
const merged = fields.genres!.mode === 'add' ? [...new Set([...current, ...names])] : current.filter((n) => !names.includes(n));
await this.metadataService.replaceGenres(bookId, merged, { executor: tx });
}
}
}
}
if (fields.tags) {
const ids = getUpdatableIds('tags');
recordResult('tags', ids);
if (ids.length > 0) {
const names = this.normalizeListValues(fields.tags.values);
if (fields.tags.mode === 'replace') {
for (const bookId of ids) {
await this.metadataService.replaceTags(bookId, names, { executor: tx });
}
} else {
const currentMap = await this.bookRepo.findTagsByBookIds(ids, tx);
for (const bookId of ids) {
const current = currentMap.get(bookId) ?? [];
const merged = fields.tags!.mode === 'add' ? [...new Set([...current, ...names])] : current.filter((n) => !names.includes(n));
await this.metadataService.replaceTags(bookId, merged, { executor: tx });
}
}
}
}
if (fields.narrators) {
const ids = getUpdatableIds('narrators');
recordResult('narrators', ids);
if (ids.length > 0) {
const names = this.normalizePersonNameListValues(fields.narrators.values);
if (fields.narrators.mode === 'replace') {
for (const bookId of ids) {
await this.narratorService.replaceForBook(bookId, names, { executor: tx });
}
} else {
const currentMap = await this.bookRepo.findNarratorsByBookIds(ids, tx);
for (const bookId of ids) {
const current = currentMap.get(bookId) ?? [];
const merged =
fields.narrators!.mode === 'add'
? this.normalizePersonNameListValues([...current, ...names])
: this.removePersonNameValues(current, names);
await this.narratorService.replaceForBook(bookId, merged, { executor: tx });
}
}
}
}
});
} catch (error) {
const durationMs = Date.now() - startedAt;
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
const errorMessage = error instanceof Error ? sanitizeLogValue(error.message) : 'unknown';
this.logger.error(
`[${event}] [fail] userId=${user.id} count=${bookIds.length} durationMs=${durationMs} errorClass=${errorClass} error="${errorMessage}" - bulk edit metadata failed`,
);
throw error;
}
if (allUpdatedBookIds.size > 0) {
await this.triggerPostMetadataUpdateEffects([...allUpdatedBookIds], user.id);
}
const result: BulkEditMetadataResult = {
updatedBooks: allUpdatedBookIds.size,
fields: fieldResults,
};
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} updatedBooks=${allUpdatedBookIds.size} fieldCount=${fieldNames.length} durationMs=${Date.now() - startedAt} - bulk edit metadata completed`,
);
return result;
}
private normalizeListValues(values: string[]): string[] {
return [...new Set(values.map((v) => v.trim()).filter((v) => v.length > 0))];
}
private normalizePersonNameListValues(values: string[]): string[] {
const names: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const name = normalizeMetadataText(value);
const key = normalizeMetadataTextKey(name);
if (!name || !key || seen.has(key)) continue;
seen.add(key);
names.push(name);
}
return names;
}
private removePersonNameValues(current: string[], namesToRemove: string[]): string[] {
const removeKeys = new Set(namesToRemove.map((name) => normalizeMetadataTextKey(name)).filter((key): key is string => key !== null));
return this.normalizePersonNameListValues(current).filter((name) => {
const key = normalizeMetadataTextKey(name);
return key ? !removeKeys.has(key) : false;
});
}
async getKoboState(id: number, user: RequestUser): Promise<BookKoboState> {
await this.verifyBookAccess(id, user);
if (!this.hasPermission(user, Permission.KoboSync)) {
return {
eligibleForKoboSync: false,
syncCollections: [],
readingState: null,
snapshots: [],
};
}
const [readingStateRow, snapshotRows, syncCollections] = await Promise.all([
this.bookRepo.findKoboReadingState(user.id, id),
this.bookRepo.findKoboSnapshotStates(user.id, id),
this.bookRepo.findKoboSyncCollectionNamesForBook(user.id, id),
]);
const currentBookmark = (readingStateRow?.currentBookmark ?? null) as Record<string, unknown> | null;
const statusInfo = (readingStateRow?.statusInfo ?? null) as Record<string, unknown> | null;
const progressCandidate = currentBookmark?.ProgressPercent;
const progressPercent = typeof progressCandidate === 'number' ? Math.max(0, Math.min(100, progressCandidate)) : null;
const status = typeof statusInfo?.Status === 'string' ? statusInfo.Status : null;
return {
eligibleForKoboSync: syncCollections.length > 0,
syncCollections,
readingState: readingStateRow
? {
status,
progressPercent,
createdAtKobo: readingStateRow.createdAtKobo ?? null,
lastModifiedKobo: readingStateRow.lastModifiedKobo ?? null,
priorityTimestamp: readingStateRow.priorityTimestamp ?? null,
updatedAt: readingStateRow.updatedAt.toISOString(),
}
: null,
snapshots: snapshotRows.map((snapshotRow) => ({
deviceId: snapshotRow.deviceId,
deviceName: snapshotRow.deviceName,
snapshotId: snapshotRow.snapshotId,
snapshotUpdatedAt: snapshotRow.snapshotUpdatedAt.toISOString(),
inSnapshot: snapshotRow.synced !== null,
synced: snapshotRow.synced ?? null,
pendingDelete: snapshotRow.pendingDelete ?? null,
isNew: snapshotRow.isNew ?? null,
removedByDevice: snapshotRow.removedByDevice ?? null,
fileHash: snapshotRow.fileHash ?? null,
metadataHash: snapshotRow.metadataHash ?? null,
})),
};
}
async refreshMetadata(id: number, preview: boolean, user: RequestUser): Promise<BookDetailDto | BookMetadataRefreshPreviewResponse> {
const event = 'book.refresh_metadata';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] bookId=${id} userId=${user.id} preview=${preview} - refresh metadata started`);
try {
const found = await this.bookRepo.findById(id);
if (!found) throw new NotFoundException(`Book ${id} not found`);
const { book, authorRows, genreRows, communityRatingRows, fileRows } = found;
await this.libraryService.verifyUserAccess(user.id, book.books.libraryId, this.isSuperuser(user));
const meta = book.book_metadata;
const providerIds = this.collectExistingProviderIds(meta ?? {});
const searchParams: MetadataSearchParams = {
title: meta?.title ?? undefined,
author: authorRows[0]?.name ?? undefined,
isbn: meta?.isbn13 ?? meta?.isbn10 ?? undefined,
seriesName: meta?.seriesName ?? undefined,
seriesIndex: meta?.seriesIndex ?? undefined,
existingProviderIds: providerIds,
hardcoverEditionId: meta?.hardcoverEditionId ?? undefined,
isAudiobook: resolveIsAudiobook(fileRows, meta),
maxCandidatesPerProvider: 1,
};
const existingFields: Partial<Record<MetadataField, unknown>> = {
title: meta?.title,
subtitle: meta?.subtitle,
description: meta?.description,
authors: authorRows.map((a) => a.name),
publisher: meta?.publisher,
publishedYear: meta?.publishedYear,
language: meta?.language,
pageCount: meta?.pageCount,
communityRating: communityRatingRows,
seriesName: meta?.seriesName,
seriesIndex: meta?.seriesIndex,
genres: genreRows.map((g) => g.name),
duration: meta?.durationSeconds ?? undefined,
abridged: meta?.abridged ?? undefined,
};
const coverState = await this.coverStore.fetchState(id);
if (!coverState) throw new NotFoundException(`Book ${id} not found`);
const coverInputs = coverFetchInputs(coverState);
Object.assign(existingFields, coverInputs.existing);
const {
resolved,
providerIds: resolvedProviderIds,
diagnostics,
} = await this.pipeline.runWithSources(searchParams, existingFields, book.books.libraryId, coverInputs.options);
if (preview) {
const previewResult = this.buildMetadataRefreshPreview(resolved, resolvedProviderIds);
const previewDiagnostics = this.buildMetadataRefreshPreviewDiagnostics(previewResult, diagnostics);
this.logger.log(
`[${event}] [end] bookId=${id} preview=true durationMs=${Date.now() - startedAt} resolvedFields=${previewDiagnostics.resolvedFieldCount} emptyReason=${previewDiagnostics.reason ?? 'none'} - refresh metadata completed`,
);
return { metadata: previewResult, diagnostics: previewDiagnostics };
}
const {
resolved: filteredResolved,
providerIds: filteredProviderIds,
skippedFields,
} = await this.bookMetadataLockService.filterResolvedMetadata(id, resolved, resolvedProviderIds);
const r = filteredResolved as Record<string, unknown>;
const dto: UpdateBookMetadataDto = {};
if (r.title !== undefined) dto.title = r.title as string | null;
if (r.subtitle !== undefined) dto.subtitle = r.subtitle as string | null;
if (r.description !== undefined) dto.description = r.description as string | null;
if (r.authors !== undefined) dto.authors = r.authors as string[];
if (r.genres !== undefined) dto.genres = r.genres as string[];
if (r.publisher !== undefined) dto.publisher = r.publisher as string | null;
if (r.publishedDate !== undefined) dto.publishedDate = r.publishedDate as string | null;
if (r.publishedYear !== undefined) dto.publishedYear = r.publishedYear as number | null;
if (r.language !== undefined) dto.language = r.language as string | null;
if (r.pageCount !== undefined) dto.pageCount = r.pageCount as number | null;
if (r.communityRatings !== undefined) dto.communityRatings = r.communityRatings as UpdateBookMetadataDto['communityRatings'];
if (r.seriesName !== undefined) dto.seriesName = r.seriesName as string | null;
if (r.seriesIndex !== undefined) dto.seriesIndex = r.seriesIndex as string | null;
if (r.seriesMemberships !== undefined) dto.seriesMemberships = r.seriesMemberships as UpdateBookMetadataDto['seriesMemberships'];
if (r.hardcoverEditionId !== undefined) dto.hardcoverEditionId = r.hardcoverEditionId as string | null;
if (r.narrators !== undefined || r.duration !== undefined || r.abridged !== undefined || r.chapters !== undefined) {
dto.audioMetadata = {};
if (r.narrators !== undefined) dto.audioMetadata.narrators = r.narrators as string[];
if (r.duration !== undefined) dto.audioMetadata.durationSeconds = r.duration as number | null;
if (r.abridged !== undefined) dto.audioMetadata.abridged = r.abridged as boolean | null;
if (r.chapters !== undefined) dto.audioMetadata.chapters = r.chapters as NonNullable<typeof dto.audioMetadata.chapters>;
}
if (r.comicMetadata !== undefined) dto.comicMetadata = r.comicMetadata as UpdateBookMetadataDto['comicMetadata'];
this.applyResolvedProviderIds(dto, filteredProviderIds);
const updatedFields = Object.keys(dto).length;
let detail: BookDetailDto | undefined;
if (updatedFields > 0) {
const saveResult = await this.updateMetadata(id, dto, user, { postSaveMode: 'schedule' });
detail = saveResult.book;
}
// Mark successful non-preview provider refreshes so freshness analytics are accurate,
// even when no scalar field changed after reconciliation.
await this.bookRepo.updateMetadataFields(id, { lastMetadataFetchAt: new Date(), updatedAt: new Date() });
let coverDownloaded = false;
for (const { medium, url, choices } of [
{ medium: 'ebook' as const, url: filteredResolved.coverUrl, choices: filteredResolved.coverChoices },
{ medium: 'audio' as const, url: filteredResolved.audioCoverUrl, choices: filteredResolved.audioCoverChoices },
]) {
if (!url) continue;
if (await this.metadataService.downloadAndSaveCover(choices ?? [{ url }], id, medium)) coverDownloaded = true;
}
if (coverDownloaded) detail = await this.getDetail(id, user);
const result = detail ?? (await this.getDetail(id, user));
this.logger.log(
`[${event}] [end] bookId=${id} preview=false durationMs=${Date.now() - startedAt} updatedFields=${updatedFields} skippedLockedFields=${skippedFields.join('|') || 'none'} coverDownloaded=${coverDownloaded} - refresh metadata completed`,
);
return result;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} preview=${preview} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - refresh metadata failed`,
);
throw err;
}
}
async bulkRefreshMetadata(
bookIds: number[],
user: RequestUser,
onProgress?: (event: { bookId: number; success: boolean; detail?: BookDetailDto; error?: string }) => void,
options?: { isCancelled?: () => boolean },
): Promise<{ processed: number; failed: number }> {
const event = 'book.bulk_refresh_metadata';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] count=${bookIds.length} userId=${user.id} - bulk refresh metadata started`);
try {
if (bookIds.length === 0) {
this.logger.log(`[${event}] [end] count=0 durationMs=${Date.now() - startedAt} processed=0 failed=0 - bulk refresh metadata completed`);
return { processed: 0, failed: 0 };
}
await this.verifyLibraryAccessForBookIds(bookIds, user);
let processed = 0;
let failed = 0;
let callbackInterrupted = false;
let cancelled = false;
for (const id of bookIds) {
if (options?.isCancelled?.()) {
cancelled = true;
break;
}
let success = false;
let detail: BookDetailDto | undefined;
let errorMessage: string | undefined;
try {
const refreshed = await this.refreshMetadata(id, false, user);
detail = refreshed as BookDetailDto;
processed++;
success = true;
} catch (err) {
const itemErrorClass = err instanceof Error ? err.name : 'Error';
const itemError = sanitizeLogValue(err instanceof Error ? err.message : String(err));
errorMessage = err instanceof Error ? err.message : String(err);
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${itemErrorClass} error="${itemError}" - bulk refresh metadata item failed`,
);
failed++;
}
try {
onProgress?.({ bookId: id, success, detail, error: errorMessage });
} catch {
callbackInterrupted = true;
break;
}
}
this.logger.log(
`[${event}] [end] count=${bookIds.length} durationMs=${Date.now() - startedAt} processed=${processed} failed=${failed} callbackInterrupted=${callbackInterrupted} cancelled=${cancelled} - bulk refresh metadata completed`,
);
return { processed, failed };
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] count=${bookIds.length} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - bulk refresh metadata failed`,
);
throw err;
}
}
async bulkReExtractCover(
bookIds: number[],
user: RequestUser,
onProgress?: (bookId: number) => void,
options?: { isCancelled?: () => boolean; medium?: CoverMedium },
): Promise<{ processed: number; updated: number }> {
const event = 'book.bulk_reextract_cover';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] count=${bookIds.length} userId=${user.id} - bulk re-extract cover started`);
try {
if (bookIds.length === 0) {
this.logger.log(`[${event}] [end] count=0 durationMs=${Date.now() - startedAt} processed=0 updated=0 - bulk re-extract cover completed`);
return { processed: 0, updated: 0 };
}
await this.verifyLibraryAccessForBookIds(bookIds, user);
const [files, coverLockedBookIds, audioCoverLockedBookIds] = await Promise.all([
this.bookRepo.findCoverSourceFilesByBookIds(bookIds),
this.bookMetadataLockService.getCoverLockedBookIds(bookIds),
this.bookMetadataLockService.getBookIdsWithLockedField(bookIds, 'audioCover'),
]);
const filesByBookId = new Map<number, (typeof files)[number][]>();
for (const file of files) {
const entries = filesByBookId.get(file.bookId) ?? [];
entries.push(file);
filesByBookId.set(file.bookId, entries);
}
const media: readonly CoverMedium[] = options?.medium ? [options.medium] : COVER_MEDIA;
let processed = 0;
let updated = 0;
let skipped = 0;
let callbackInterrupted = false;
let cancelled = false;
for (const id of bookIds) {
if (options?.isCancelled?.()) {
cancelled = true;
break;
}
const candidateFiles = filesByBookId.get(id) ?? [];
const sources = selectEmbeddedCoverSources(candidateFiles, candidateFiles[0]?.formatPriority ?? DEFAULT_FORMAT_PRIORITY);
const targets = media.flatMap((medium) => {
const source = sources[medium];
return source ? [{ medium, source }] : [];
});
if (targets.length === 0) continue;
const unlocked = targets.filter(({ medium }) => !(medium === 'audio' ? audioCoverLockedBookIds : coverLockedBookIds).has(id));
if (unlocked.length === 0) {
skipped++;
continue;
}
processed++;
let saved = false;
for (const { medium, source } of unlocked) {
saved = (await this.metadataService.refreshCoverForBook(id, source.absolutePath, source.format ?? '', medium)) || saved;
}
if (saved) {
updated++;
}
if (!options?.medium) await this.coverReconciler?.enqueue([id]);
try {
onProgress?.(id);
} catch {
callbackInterrupted = true;
break;
}
}
this.logger.log(
`[${event}] [end] count=${bookIds.length} durationMs=${Date.now() - startedAt} processed=${processed} updated=${updated} skippedLocked=${skipped} callbackInterrupted=${callbackInterrupted} cancelled=${cancelled} - bulk re-extract cover completed`,
);
return { processed, updated };
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] count=${bookIds.length} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - bulk re-extract cover failed`,
);
throw err;
}
}
private async resolveExportFileSize(file: ExportCandidateFile): Promise<number> {
if (typeof file.sizeBytes === 'number' && Number.isFinite(file.sizeBytes) && file.sizeBytes >= 0) {
return file.sizeBytes;
}
try {
const { size } = await stat(file.absolutePath);
return size;
} catch (err) {
if (this.isMissingFilesystemEntry(err)) {
throw new NotFoundException(`File for book ${file.bookId} is missing on disk`);
}
throw err;
}
}
async getExportFiles(bookIds: number[], user: RequestUser, scope: ExportScope): Promise<ExportPlan> {
const event = 'book.get_export_files';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] count=${bookIds.length} userId=${user.id} scope=${scope} - get export files started`);
try {
const uniqueBookIds = [...new Set(bookIds)];
if (uniqueBookIds.length === 0) throw new BadRequestException('No books selected');
if (uniqueBookIds.length > EXPORT_LIMITS.MAX_BOOKS) {
throw new BadRequestException(`Too many books selected. Limit is ${EXPORT_LIMITS.MAX_BOOKS}.`);
}
const rows = await this.bookRepo.findLibraryIdsByBookIds(uniqueBookIds);
const foundBookIds = new Set(rows.map((row) => row.id));
const missingBookIds = uniqueBookIds.filter((id) => !foundBookIds.has(id));
if (missingBookIds.length > 0) {
throw new BadRequestException(`Some selected books were not found: ${missingBookIds.join(', ')}`);
}
const uniqueLibraryIds = [...new Set(rows.map((row) => row.libraryId))];
await Promise.all(uniqueLibraryIds.map((libraryId) => this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user))));
const bookOrder = new Map(uniqueBookIds.map((id, index) => [id, index]));
const fetchedFiles: ExportCandidateFile[] =
scope === 'primary' ? await this.bookRepo.findPrimaryFilesByBookIds(uniqueBookIds) : await this.bookRepo.findAllFilesByBookIds(uniqueBookIds);
const files = scope === 'audio' ? fetchedFiles.filter((file) => !!file.format && isAudioFormat(file.format)) : fetchedFiles;
const booksWithFiles = new Set(files.map((file) => file.bookId));
const booksWithoutExportFiles = uniqueBookIds.filter((id) => !booksWithFiles.has(id));
if (booksWithoutExportFiles.length > 0) {
throw new BadRequestException(`Some selected books have no exportable files: ${booksWithoutExportFiles.join(', ')}`);
}
if (files.length > EXPORT_LIMITS.MAX_FILES) {
throw new BadRequestException(`Too many files selected for export. Limit is ${EXPORT_LIMITS.MAX_FILES}.`);
}
const orderedFiles = files.sort((a, b) => {
const aOrder = bookOrder.get(a.bookId) ?? Number.MAX_SAFE_INTEGER;
const bOrder = bookOrder.get(b.bookId) ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) return aOrder - bOrder;
const aSortOrder = a.sortOrder ?? 0;
const bSortOrder = b.sortOrder ?? 0;
if (aSortOrder !== bSortOrder) return aSortOrder - bSortOrder;
return a.absolutePath.localeCompare(b.absolutePath);
});
const [pattern, metadataRows] = await Promise.all([
this.appSettings.getDownloadPattern(),
this.bookRepo.findPatternMetadataByBookIds([...new Set(orderedFiles.map((f) => f.bookId))]),
]);
const metadataByBookId = new Map(metadataRows.map((row) => [row.bookId, row]));
const archiveFilename = this.resolveExportArchiveFilename(uniqueBookIds.length, orderedFiles, metadataByBookId, pattern);
const usedPaths = new Set<string>();
let projectedBytes = 0;
const result: ExportPlan['files'] = [];
for (const file of orderedFiles) {
const sizeBytes = await this.resolveExportFileSize(file);
projectedBytes += sizeBytes;
if (projectedBytes > EXPORT_LIMITS.MAX_PROJECTED_BYTES) {
throw new BadRequestException(`Export exceeds projected size limit of ${EXPORT_LIMITS.MAX_PROJECTED_BYTES} bytes.`);
}
const tokens = this.buildDownloadPatternTokens(file.absolutePath, file.format, file.mediaOverlayAvailable, metadataByBookId.get(file.bookId));
const resolvedPath = resolveUploadPath(pattern || DEFAULT_DOWNLOAD_PATTERN, tokens, tokens.extension);
const fallbackFilename = basename(file.absolutePath);
const rawZipPath = resolvedPath ?? fallbackFilename;
const safeZipPath = this.sanitizeZipPath(rawZipPath, fallbackFilename);
const zipPath = this.makeUniqueZipPath(safeZipPath, usedPaths);
result.push({ absolutePath: file.absolutePath, zipPath, sizeBytes });
}
this.logger.log(
`[${event}] [end] count=${uniqueBookIds.length} durationMs=${Date.now() - startedAt} files=${result.length} projectedBytes=${projectedBytes} scope=${scope} - get export files completed`,
);
return {
files: result,
projectedBytes,
bookCount: uniqueBookIds.length,
scope,
archiveFilename,
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] count=${bookIds.length} userId=${user.id} scope=${scope} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - get export files failed`,
);
throw err;
}
}
async getDetail(id: number, user: RequestUser): Promise<BookDetailDto> {
await this.verifyBookAccess(id, user);
const [result, personalRating, personalNote, readStatus, comicMeta, collectionRows, readAloudSyncMode, coverContext] = await Promise.all([
this.bookRepo.findById(id),
this.bookRepo.findRatingByBookAndUser(id, user.id),
this.userBookNoteService.findOne(user.id, id),
this.userBookStatusService.findOne(user.id, id),
this.comicMetadataService.findByBookId(id),
this.bookRepo.findCollectionsByBookId(id, user.id),
this.bookRepo.findReadAloudSyncMode(user.id, id),
this.coverStore.contextFor(id),
]);
if (!result) throw new NotFoundException(`Book ${id} not found`);
const { book, authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows, communityRatingRows } = result;
const meta = book.book_metadata;
const customMetadata = await this.customMetadataService.getBookValues(id, book.books.libraryId);
const hasAudioFiles = fileRows.some((f) => f.format && isAudioFormat(f.format));
const orderedFileRows = hasAudioFiles
? [...fileRows].sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath)))
: fileRows;
const resolvedChapters = this.resolveChapters(meta?.chapters as AudiobookChapter[] | null | undefined, orderedFileRows);
const supplementalFields = buildBookDetailSupplementalFields({
readStatus,
hasAudioFiles,
narratorRows,
audioMeta: {
durationSeconds: meta?.durationSeconds,
abridged: meta?.abridged,
},
chapters: resolvedChapters,
comicMeta,
collections: collectionRows,
});
const mediaOverlayByFileId = await this.resolveMediaOverlayCapabilities(fileRows);
const readAloudSync = this.buildReadAloudProgressSync(fileRows, mediaOverlayByFileId, book.books.primaryFileId, readAloudSyncMode);
return {
id: book.books.id,
libraryId: book.books.libraryId,
libraryName: book.libraries?.name ?? '',
status: book.books.status,
folderPath: book.books.folderPath,
addedAt: book.books.addedAt,
updatedAt: book.books.updatedAt ?? null,
title: meta?.title ?? null,
subtitle: meta?.subtitle ?? null,
description: meta?.description ?? null,
isbn10: meta?.isbn10 ?? null,
isbn13: meta?.isbn13 ?? null,
publisher: meta?.publisher ?? null,
publishedDate: meta?.publishedDate ?? null,
publishedYear: meta?.publishedYear ?? null,
language: meta?.language ?? null,
pageCount: meta?.pageCount ?? null,
seriesId: meta?.seriesId ?? null,
seriesName: meta?.seriesName ?? null,
seriesIndex: meta?.seriesIndex ?? null,
seriesMemberships: seriesMembershipRows,
rating: personalRating,
personalNote: personalNote?.note ?? null,
personalNoteUpdatedAt: personalNote ? new Date(personalNote.updatedAt) : null,
communityRatings: this.mapCommunityRatingRows(communityRatingRows),
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
coverMedia: [...(coverContext?.media.hasEbook ? (['ebook'] as const) : []), ...(coverContext?.media.hasAudio ? (['audio'] as const) : [])],
covers: coverContext ? this.coverStore.slotDtos(coverContext) : { ebook: null, audio: null },
coverVersion: this.coverStore.coverVersion(
book.libraries?.coverAspectRatio ?? '2/3',
coverContext?.slots ?? [],
(meta?.coverUpdatedAt ?? book.books.updatedAt ?? book.books.addedAt).toISOString(),
),
hardcoverEditionId: meta?.hardcoverEditionId ?? null,
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
providerIds: {
[MetadataProviderKey.GOOGLE]: meta?.googleBooksId ?? null,
[MetadataProviderKey.GOODREADS]: meta?.goodreadsId ?? null,
[MetadataProviderKey.AMAZON]: meta?.amazonId ?? null,
[MetadataProviderKey.HARDCOVER]: meta?.hardcoverId ?? null,
[MetadataProviderKey.OPEN_LIBRARY]: meta?.openLibraryId ?? null,
[MetadataProviderKey.ITUNES]: meta?.itunesId ?? null,
[MetadataProviderKey.AUDIBLE]: meta?.audibleId ?? null,
[MetadataProviderKey.LIBROFM]: meta?.librofmId ?? null,
[MetadataProviderKey.KOBO]: meta?.koboId ?? null,
[MetadataProviderKey.COMICVINE]: meta?.comicvineId ?? null,
[MetadataProviderKey.RANOBEDB]: meta?.ranobedbId ?? null,
[MetadataProviderKey.LUBIMYCZYTAC]: meta?.lubimyczytacId ?? null,
[MetadataProviderKey.ALADIN]: meta?.aladinId ?? null,
},
authors: authorRows,
genres: genreRows.map((g) => g.name),
tags: tagRows.map((t) => t.name),
files: orderedFileRows.map((f) => ({
id: f.id,
format: f.format,
role: f.id === book.books.primaryFileId ? 'primary' : f.role,
sizeBytes: f.sizeBytes,
absolutePath: f.absolutePath,
createdAt: f.createdAt,
filename: basename(f.absolutePath),
durationSeconds: f.durationSeconds,
mediaOverlay: mediaOverlayByFileId.get(f.id) ?? null,
})),
lastWrittenAt: meta?.lastWrittenAt ?? null,
metadataScore: meta?.metadataScore ?? null,
readAloudSync,
formatPriority: (book.libraries?.formatPriority as string[] | null) ?? [],
customMetadata,
fileWriteStatus: this.fileWriteService?.resolveBookFileWriteStatus(book.libraries, orderedFileRows, book.books.primaryFileId) ?? {
enabled: false,
reason: 'library_disabled',
writableFormats: [],
writableFields: [],
},
...supplementalFields,
};
}
private async resolveMediaOverlayCapabilities(
fileRows: Array<{
id: number;
format: string | null;
absolutePath: string;
mediaOverlayAvailable?: boolean | null;
mediaOverlayDurationSeconds?: number | null;
mediaOverlayCheckedAt?: Date | null;
}>,
): Promise<Map<number, EpubMediaOverlayCapability | null>> {
const entries = await Promise.all(
fileRows.map(async (file) => {
if (file.format?.toLowerCase() !== 'epub') return [file.id, null] as const;
const stored = mediaOverlayCapabilityFromFields(file);
if (stored) return [file.id, stored] as const;
const fields = await inspectEpubMediaOverlayFields(file.absolutePath, file.format, (err) => {
const error = err instanceof Error ? err : new Error(String(err));
this.logger.warn(
`[book.media_overlay_capability] [fail] fileId=${file.id} errorClass=${error.constructor.name} error="${sanitizeLogValue(error.message)}" - EPUB media-overlay inspection failed`,
);
});
await this.bookRepo.updateBookFile(file.id, fields);
return [file.id, mediaOverlayCapabilityFromFields({ ...file, ...fields })] as const;
}),
);
return new Map(entries);
}
private buildReadAloudProgressSync(
fileRows: Array<{ id: number; format: string | null; durationSeconds: number | null }>,
mediaOverlayByFileId: Map<number, EpubMediaOverlayCapability | null>,
primaryFileId: number | null,
mode: ReadAloudProgressSyncMode,
): ReadAloudProgressSync {
const overlayFiles = fileRows
.map((file) => ({ file, mediaOverlay: mediaOverlayByFileId.get(file.id) ?? null }))
.filter(({ file, mediaOverlay }) => file.format?.toLowerCase() === 'epub' && mediaOverlay?.available === true);
const selectedOverlay = overlayFiles.find(({ file }) => file.id === primaryFileId) ?? overlayFiles[0] ?? null;
const audioFiles = fileRows.filter((file) => file.format && isAudioFormat(file.format));
const audioDurationSeconds = this.sumPositiveDurations(audioFiles);
const overlayDurationSeconds = selectedOverlay?.mediaOverlay?.durationSeconds ?? null;
const durationDifferenceSeconds =
audioDurationSeconds != null && overlayDurationSeconds != null ? Math.abs(audioDurationSeconds - overlayDurationSeconds) : null;
const durationDifferenceRatio =
durationDifferenceSeconds != null && overlayDurationSeconds != null && overlayDurationSeconds > 0
? durationDifferenceSeconds / overlayDurationSeconds
: null;
const base: Omit<ReadAloudProgressSync, 'state' | 'unavailableReason'> = {
mode,
overlayFileId: selectedOverlay?.file.id ?? null,
audioDurationSeconds,
overlayDurationSeconds,
durationDifferenceSeconds,
durationDifferenceRatio,
koreaderDownloadAvailable: selectedOverlay != null,
};
if (mode === 'disabled') {
return { ...base, state: 'disabled', unavailableReason: null };
}
if (!selectedOverlay) {
return { ...base, state: 'unavailable', unavailableReason: 'no_media_overlay_epub' };
}
if (audioFiles.length === 0) {
return { ...base, state: 'unavailable', unavailableReason: 'no_audio_files' };
}
if (audioDurationSeconds == null || overlayDurationSeconds == null || overlayDurationSeconds <= 0) {
return { ...base, state: 'unavailable', unavailableReason: 'missing_duration' };
}
const tolerance = Math.min(READ_ALOUD_MAX_DURATION_DIFF_SECONDS, overlayDurationSeconds * READ_ALOUD_MAX_DURATION_DIFF_RATIO);
if (durationDifferenceSeconds != null && durationDifferenceSeconds > tolerance) {
return { ...base, state: 'unavailable', unavailableReason: 'duration_mismatch' };
}
return { ...base, state: 'enabled', unavailableReason: null };
}
private sumPositiveDurations(files: Array<{ durationSeconds: number | null }>): number | null {
let total = 0;
for (const file of files) {
if (typeof file.durationSeconds !== 'number' || !Number.isFinite(file.durationSeconds) || file.durationSeconds <= 0) return null;
total += file.durationSeconds;
}
return total > 0 ? total : null;
}
async writeAndRename(bookId: number, user: RequestUser): Promise<BookWriteAndRenameResult> {
await this.verifyBookAccess(bookId, user);
this.fileWriteService?.cancelPendingWrite(bookId);
this.fileRenameService?.cancelPendingRename(bookId);
const settings = (await this.fileWriteService?.findLibraryWriteSettingsForBook(bookId)) ?? null;
let write: WriteResult;
try {
write = (await this.fileWriteService?.writeToFile(bookId, 'sync', user.id, false, true, true)) ?? {
status: 'skipped',
fieldsWritten: [],
durationMs: 0,
reason: 'file write service unavailable',
};
} catch (err) {
write = {
status: 'failed',
fieldsWritten: [],
durationMs: 0,
reason: err instanceof Error ? err.message : String(err),
};
}
let rename: FileRenameResult;
try {
rename = (await this.fileRenameService?.performRename(bookId, user.id, true, true)) ?? {
status: 'skipped',
durationMs: 0,
reason: 'file rename service unavailable',
};
} catch (err) {
rename = {
status: 'failed',
durationMs: 0,
reason: err instanceof Error ? err.message : String(err),
};
}
return {
write,
rename,
libraryAutoWriteEnabled: settings?.fileWriteEnabled ?? false,
libraryAutoRenameEnabled: settings?.fileRenameEnabled ?? false,
};
}
async updatePersonalNote(id: number, dto: UpdatePersonalNoteDto, user: RequestUser): Promise<BookDetailDto> {
await this.verifyBookAccess(id, user);
await this.userBookNoteService.setNote(user.id, id, dto.note ?? null);
return this.getDetail(id, user);
}
async getMetadataFromFile(id: number, user: RequestUser): Promise<Record<string, unknown>> {
await this.verifyBookAccess(id, user);
const file = await this.bookRepo.findPrimaryFile(id);
if (!file) throw new NotFoundException(`Book ${id} has no primary file`);
const { absolutePath, format } = file;
if (!format) return {};
switch (format) {
case 'epub': {
const parsed = await extractEpubMetadata(absolutePath);
if (!parsed) return {};
const customMetadata = await this.customMetadataService.parseFileValuesForBook(id, parsed.customMetadata);
return {
title: parsed.title,
subtitle: parsed.subtitle,
description: parsed.description,
publisher: parsed.publisher,
publishedDate: parsed.publishedDate,
publishedYear: parsed.publishedYear,
language: parsed.language,
pageCount: parsed.pageCount,
isbn10: parsed.isbn10,
isbn13: parsed.isbn13,
seriesName: parsed.seriesName,
seriesIndex: parsed.seriesIndex,
googleBooksId: parsed.googleBooksId,
goodreadsId: parsed.goodreadsId,
amazonId: parsed.amazonId,
hardcoverId: parsed.hardcoverId,
hardcoverEditionId: parsed.hardcoverEditionId,
openLibraryId: parsed.openLibraryId,
itunesId: parsed.itunesId,
koboId: parsed.koboId,
ranobedbId: parsed.ranobedbId,
lubimyczytacId: parsed.lubimyczytacId,
aladinId: parsed.aladinId,
customMetadata: customMetadata.length > 0 ? customMetadata : undefined,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
};
}
case 'pdf': {
const parsed = await parsePdfFile(absolutePath, {
extractCover: false,
onWarning: (warning) => this.logPdfFileMetadataWarning(warning),
});
if (!parsed) return {};
return {
title: parsed.title,
subtitle: parsed.subtitle,
description: parsed.description,
publisher: parsed.publisher,
publishedDate: parsed.publishedDate,
publishedYear: parsed.publishedYear,
language: parsed.language,
pageCount: parsed.pageCount,
isbn10: parsed.isbn10,
isbn13: parsed.isbn13,
seriesName: parsed.seriesName,
seriesIndex: parsed.seriesIndex,
googleBooksId: parsed.googleBooksId,
goodreadsId: parsed.goodreadsId,
amazonId: parsed.amazonId,
hardcoverId: parsed.hardcoverId,
hardcoverEditionId: parsed.hardcoverEditionId,
openLibraryId: parsed.openLibraryId,
itunesId: parsed.itunesId,
koboId: parsed.koboId,
ranobedbId: parsed.ranobedbId,
lubimyczytacId: parsed.lubimyczytacId,
aladinId: parsed.aladinId,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
};
}
case 'mobi':
case 'azw3':
case 'azw': {
const parsed = await parseMobiFile(absolutePath);
if (!parsed) return {};
const publishedDate = normalizePublishedDate(parsed.publishedDate ?? undefined) ?? undefined;
const year = publishedDate ? publishedYearFromDateKey(publishedDate) : undefined;
return {
title: parsed.title,
description: parsed.description,
publisher: parsed.publisher,
publishedDate,
publishedYear: year,
language: parsed.language,
isbn13: parsed.isbn,
authors: parsed.authors.length > 0 ? parsed.authors : undefined,
genres: parsed.tags.length > 0 ? parsed.tags : undefined,
};
}
case 'cbz':
case 'cbr':
case 'cb7': {
const extractor = format === 'cbz' ? extractCbzMetadata : format === 'cbr' ? extractCbrMetadata : extractCb7Metadata;
const parsed = await extractor(absolutePath);
if (!parsed) return {};
const cbzGenres = parsed.genres.length > 0 ? parsed.genres : parsed.tags;
return {
title: parsed.title,
subtitle: parsed.subtitle,
description: parsed.description,
publisher: parsed.publisher,
publishedDate: parsed.publishedDate,
publishedYear: parsed.publishedYear,
language: parsed.language,
pageCount: parsed.pageCount,
isbn10: parsed.isbn10,
isbn13: parsed.isbn13,
seriesName: parsed.seriesName,
seriesIndex: parsed.seriesIndex,
googleBooksId: parsed.googleBooksId,
goodreadsId: parsed.goodreadsId,
amazonId: parsed.amazonId,
hardcoverId: parsed.hardcoverId,
hardcoverEditionId: parsed.hardcoverEditionId,
openLibraryId: parsed.openLibraryId,
itunesId: parsed.itunesId,
koboId: parsed.koboId,
ranobedbId: parsed.ranobedbId,
lubimyczytacId: parsed.lubimyczytacId,
aladinId: parsed.aladinId,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: cbzGenres.length > 0 ? cbzGenres : undefined,
comicMetadata: parsed.comicMetadata ?? undefined,
};
}
case 'fb2': {
const parsed = await parseFb2File(absolutePath);
if (!parsed) return {};
return {
title: parsed.title,
description: parsed.description,
publishedDate: parsed.publishedDate,
publishedYear: parsed.publishedYear,
language: parsed.language,
seriesName: parsed.seriesName,
seriesIndex: parsed.seriesIndex,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
};
}
default: {
if (isAudioFormat(format)) {
const parsed = await extractAudioMetadata(absolutePath);
if (!parsed) return {};
const result: Record<string, unknown> = {};
if (parsed.title !== null) result.title = parsed.title;
if (parsed.subtitle !== null) result.subtitle = parsed.subtitle;
if (parsed.description !== null) result.description = parsed.description;
if (parsed.publisher !== null) result.publisher = parsed.publisher;
if (parsed.publishedDate !== null) result.publishedDate = parsed.publishedDate;
if (parsed.publishedYear !== null) result.publishedYear = parsed.publishedYear;
if (parsed.language !== null) result.language = parsed.language;
if (parsed.seriesName !== null) result.seriesName = parsed.seriesName;
if (parsed.seriesIndex !== null) result.seriesIndex = parsed.seriesIndex;
if (parsed.audibleId !== null) result.audibleId = parsed.audibleId;
if (parsed.librofmId !== null) result.librofmId = parsed.librofmId;
if (parsed.durationSeconds !== null) result.durationSeconds = parsed.durationSeconds;
if (parsed.authors.length > 0) result.authors = parsed.authors.map((a) => a.name);
if (parsed.genres.length > 0) result.genres = parsed.genres;
if (parsed.narrators.length > 0) result.narrators = parsed.narrators;
return result;
}
return {};
}
}
}
private logPdfFileMetadataWarning(warning: PdfParseWarning): void {
const pathValue = sanitizeLogValue(warning.absolutePath);
if (warning.code === 'buffered-large-pdf') {
this.logger.warn(
`[book.file_metadata_pdf] [end] path="${pathValue}" code=${warning.code} sizeBytes=${warning.sizeBytes ?? 0} thresholdBytes=${warning.thresholdBytes ?? 0} - large pdf buffered in memory`,
);
return;
}
const errorMessage = sanitizeLogValue(warning.errorMessage);
this.logger.warn(
`[book.file_metadata_pdf] [fail] path="${pathValue}" code=${warning.code} errorClass=${warning.errorClass} error="${errorMessage}" - pdf file metadata warning emitted`,
);
}
private async triggerPostMetadataUpdateEffects(bookIds: number[], userId: number): Promise<void> {
for (const bookId of bookIds) {
this.fileWriteService?.scheduleWrite(bookId, 'auto', userId);
this.fileRenameService?.scheduleRename(bookId, userId);
}
await this.scoreService.calculateAndSaveMany(bookIds);
}
private resolveChapters(
stored: AudiobookChapter[] | null | undefined,
fileRows: { absolutePath: string; format: string | null; durationSeconds: number | null; sortOrder?: number | null }[],
): AudiobookChapter[] | null {
if (stored && stored.length > 0) return stored;
const audioFiles = fileRows.filter((f) => f.format && isAudioFormat(f.format)).sort(compareAudioTracks);
if (audioFiles.length < 2) return stored ?? null;
// Offsets past a track of unknown length would be wrong; clients fall back to one entry per track.
if (audioFiles.some((f) => f.durationSeconds === null || f.durationSeconds <= 0)) return stored ?? null;
const chapters: AudiobookChapter[] = [];
let offsetMs = 0;
for (const f of audioFiles) {
const nameWithExt = basename(f.absolutePath);
const title = nameWithExt.replace(/\.[^.]+$/, '');
chapters.push({ title, startMs: offsetMs });
offsetMs += Math.round((f.durationSeconds ?? 0) * 1000);
}
return chapters;
}
}