mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
068a62b7ab
Use the package media:duration when a SMIL clip cannot be measured. Storyteller 2.x writes a backwards clip at audio track boundaries, which nulled the total and blocked the audiobook bridge. Stored nulls are re-measured via a migration, the detail page and an unchanged-folder scan. Fixes #1619
3709 lines
161 KiB
TypeScript
3709 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;
|
|
|
|
if (file.mediaOverlayCheckedAt) return [file.id, mediaOverlayCapabilityFromFields(file)] 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;
|
|
}
|
|
}
|