mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(metadata): preserve embedded metadata on new book imports
Ensure that new book imports via scanner or file upload prioritize and preserve embedded metadata. Pass a new `preserveExisting` option to the metadata fetch pipeline to prevent external providers from overwriting existing fields or covers that were extracted from the book files during import. Closes #612
This commit is contained in:
@@ -43,7 +43,11 @@ describe('registerAuthGuard', () => {
|
||||
provisioningMethod: 'local',
|
||||
settings: { achievementPreferences: { enabled: false } },
|
||||
})
|
||||
registerAuthGuard({ beforeEach: vi.fn((callback: (to: TestRoute) => Promise<unknown>) => (guard = callback)) } as never)
|
||||
registerAuthGuard({
|
||||
beforeEach: vi.fn<(callback: (to: TestRoute) => Promise<unknown>) => void>((callback) => {
|
||||
guard = callback
|
||||
}),
|
||||
} as never)
|
||||
})
|
||||
|
||||
it('redirects disabled achievement routes to Account profile settings', async () => {
|
||||
|
||||
+102
-78
@@ -20,10 +20,11 @@ function makeService(withGateway = true) {
|
||||
resetAllProcessingOnBoot: vi.fn().mockResolvedValue(0),
|
||||
recoverStuckProcessing: vi.fn().mockResolvedValue(0),
|
||||
fetchDue: vi.fn().mockResolvedValue([]),
|
||||
upsertSchedule: vi.fn().mockResolvedValue(0),
|
||||
scheduleEligibleBookIdsInBatches: vi.fn().mockResolvedValue(0),
|
||||
markProcessing: vi.fn().mockResolvedValue(true),
|
||||
markDone: vi.fn().mockResolvedValue(undefined),
|
||||
markFailed: vi.fn().mockResolvedValue(undefined),
|
||||
deferProcessing: vi.fn().mockResolvedValue(undefined),
|
||||
getStatusSummary: vi.fn().mockResolvedValue({ queued: 0, processing: 0, failed: 0 }),
|
||||
scheduleEligibleBooksInBatches: vi.fn().mockResolvedValue(0),
|
||||
cancelPending: vi.fn().mockResolvedValue(0),
|
||||
@@ -54,6 +55,9 @@ function makeService(withGateway = true) {
|
||||
upsertComicMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
downloadAndSaveCover: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const comicMetadataRepository = {
|
||||
findByBookId: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
const scoreService = {
|
||||
calculateAndSave: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -82,6 +86,7 @@ function makeService(withGateway = true) {
|
||||
bookReadService as never,
|
||||
pipeline as never,
|
||||
metadataService as never,
|
||||
comicMetadataRepository as never,
|
||||
scoreService as never,
|
||||
bookMetadataLockService as never,
|
||||
session,
|
||||
@@ -98,6 +103,7 @@ function makeService(withGateway = true) {
|
||||
bookReadService,
|
||||
pipeline,
|
||||
metadataService,
|
||||
comicMetadataRepository,
|
||||
scoreService,
|
||||
bookMetadataLockService,
|
||||
session,
|
||||
@@ -171,42 +177,15 @@ describe('BookMetadataFetchOrchestratorService', () => {
|
||||
expect(session.getSnapshot().sessionTotal).toBe(4);
|
||||
});
|
||||
|
||||
it('scheduleIfEligible only queues when trigger-on-import is enabled and eligibility passes', async () => {
|
||||
const { service, configService, eligibilityService, queueRepo, bookReadService, session } = makeService();
|
||||
it('schedules imported books in bounded batches only when import auto-fetch is enabled', async () => {
|
||||
const { service, configService, queueRepo, session } = makeService();
|
||||
configService.getEffectiveConfig.mockResolvedValue(baseConfig(true, true));
|
||||
bookReadService.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: { libraryId: 7 },
|
||||
book_metadata: {
|
||||
metadataScore: 50,
|
||||
lastMetadataFetchAt: null,
|
||||
title: 'Book',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
communityRating: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
coverSource: null,
|
||||
durationSeconds: null,
|
||||
abridged: null,
|
||||
},
|
||||
},
|
||||
authorRows: [],
|
||||
genreRows: [],
|
||||
narratorRows: [],
|
||||
communityRatingRows: [],
|
||||
});
|
||||
eligibilityService.isEligible.mockReturnValue(true);
|
||||
queueRepo.upsertSchedule = vi.fn().mockResolvedValue(1);
|
||||
queueRepo.scheduleEligibleBookIdsInBatches.mockResolvedValue(2);
|
||||
|
||||
await service.scheduleIfEligible(99, 7, 'import' as any);
|
||||
await expect(service.scheduleImportedBooksIfEligible(7, [99, 100])).resolves.toBe(2);
|
||||
|
||||
expect(queueRepo.upsertSchedule).toHaveBeenCalledWith([99], 'import');
|
||||
expect(session.getSnapshot().sessionTotal).toBe(1);
|
||||
expect(queueRepo.scheduleEligibleBookIdsInBatches).toHaveBeenCalledWith(baseConfig(true, true), 'event_import', 7, [99, 100], 1000);
|
||||
expect(session.getSnapshot().sessionTotal).toBe(2);
|
||||
});
|
||||
|
||||
it('pause, resume, cancelPending, and requeueFailed update orchestrator/session state', async () => {
|
||||
@@ -387,13 +366,13 @@ describe('BookMetadataFetchOrchestratorService', () => {
|
||||
|
||||
it('pollOnce processes due items and waits random delay', async () => {
|
||||
const { service, queueRepo } = makeService();
|
||||
queueRepo.fetchDue.mockResolvedValue([{ bookId: 44, title: 'Queued Book' }]);
|
||||
queueRepo.fetchDue.mockResolvedValue([{ bookId: 44, title: 'Queued Book', reason: 'manual_trigger' }]);
|
||||
const processSpy = vi.spyOn(service as any, 'processOne').mockResolvedValue(undefined);
|
||||
const delaySpy = vi.spyOn(service as any, 'randomDelay').mockResolvedValue(undefined);
|
||||
|
||||
await (service as any).pollOnce();
|
||||
|
||||
expect(processSpy).toHaveBeenCalledWith(44, 'Queued Book');
|
||||
expect(processSpy).toHaveBeenCalledWith(44, 'Queued Book', 'manual_trigger');
|
||||
expect(delaySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -407,6 +386,87 @@ describe('BookMetadataFetchOrchestratorService', () => {
|
||||
expect(queueRepo.markDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers an event-import job while its book is still being scanned', async () => {
|
||||
const { service, queueRepo, bookReadService, pipeline } = makeService();
|
||||
bookReadService.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: { libraryId: 2, status: 'processing' },
|
||||
book_metadata: { title: null },
|
||||
},
|
||||
authorRows: [],
|
||||
genreRows: [],
|
||||
narratorRows: [],
|
||||
communityRatingRows: [],
|
||||
});
|
||||
|
||||
await (service as any).processOne(31, 'Importing', 'event_import');
|
||||
|
||||
expect(queueRepo.deferProcessing).toHaveBeenCalledWith(31);
|
||||
expect(pipeline.runWithSources).not.toHaveBeenCalled();
|
||||
expect(queueRepo.markDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('discards an event-import job if auto-fetch is disabled before it runs', async () => {
|
||||
const { service, configService, queueRepo, bookReadService, pipeline } = makeService();
|
||||
configService.getEffectiveConfig.mockResolvedValue(baseConfig(false, true));
|
||||
bookReadService.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: { libraryId: 2, status: 'present' },
|
||||
book_metadata: { title: 'Imported', lastMetadataFetchAt: null },
|
||||
},
|
||||
authorRows: [],
|
||||
genreRows: [],
|
||||
narratorRows: [],
|
||||
communityRatingRows: [],
|
||||
});
|
||||
|
||||
await (service as any).processOne(32, 'Imported', 'event_import');
|
||||
|
||||
expect(queueRepo.markDone).toHaveBeenCalledWith(32);
|
||||
expect(pipeline.runWithSources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs event-import fetches in preserve-existing mode after local metadata is available', async () => {
|
||||
const { service, bookReadService, pipeline, comicMetadataRepository } = makeService();
|
||||
bookReadService.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: { libraryId: 2, status: 'present' },
|
||||
book_metadata: {
|
||||
title: 'Akira',
|
||||
isbn13: null,
|
||||
isbn10: null,
|
||||
durationSeconds: null,
|
||||
audibleId: null,
|
||||
hardcoverEditionId: null,
|
||||
chapters: null,
|
||||
lastMetadataFetchAt: null,
|
||||
},
|
||||
},
|
||||
authorRows: [{ name: 'Katsuhiro Otomo' }],
|
||||
genreRows: [{ name: 'Manga' }],
|
||||
narratorRows: [],
|
||||
communityRatingRows: [],
|
||||
});
|
||||
comicMetadataRepository.findByBookId.mockResolvedValue({ issueNumber: '28', volumeName: null });
|
||||
pipeline.runWithSources.mockResolvedValue({ resolved: {}, providerIds: {} });
|
||||
vi.spyOn(service as any, 'persistResolved').mockResolvedValue(undefined);
|
||||
|
||||
await (service as any).processOne(33, 'Akira', 'event_import');
|
||||
|
||||
expect(comicMetadataRepository.findByBookId).toHaveBeenCalledWith(33);
|
||||
expect(pipeline.runWithSources).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Akira' }),
|
||||
expect.objectContaining({
|
||||
title: 'Akira',
|
||||
authors: ['Katsuhiro Otomo'],
|
||||
genres: ['Manga'],
|
||||
comicMetadata: { issueNumber: '28', volumeName: null },
|
||||
}),
|
||||
2,
|
||||
{ preserveExisting: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('processOne marks done and clears session when metadata pipeline succeeds', async () => {
|
||||
const { service, queueRepo, bookReadService, pipeline, scoreService, session } = makeService();
|
||||
session.addToTotal(1);
|
||||
@@ -474,53 +534,17 @@ describe('BookMetadataFetchOrchestratorService', () => {
|
||||
expect(resetSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('scheduleIfEligible skips queue writes when disabled, import trigger is disabled, book is missing, or eligibility fails', async () => {
|
||||
const { service, configService, eligibilityService, queueRepo, bookReadService } = makeService();
|
||||
it('does not schedule imported books when auto-fetch or its import trigger is disabled', async () => {
|
||||
const { service, configService, queueRepo } = makeService();
|
||||
configService.getEffectiveConfig.mockResolvedValue(baseConfig(false, true));
|
||||
|
||||
await service.scheduleIfEligible(1, 2, 'import' as never);
|
||||
expect(bookReadService.findById).not.toHaveBeenCalled();
|
||||
expect(queueRepo.upsertSchedule).not.toHaveBeenCalled();
|
||||
await expect(service.scheduleImportedBooksIfEligible(2, [1])).resolves.toBe(0);
|
||||
expect(queueRepo.scheduleEligibleBookIdsInBatches).not.toHaveBeenCalled();
|
||||
|
||||
configService.getEffectiveConfig.mockResolvedValue(baseConfig(true, false));
|
||||
|
||||
await service.scheduleIfEligible(1, 2, 'import' as never);
|
||||
expect(queueRepo.upsertSchedule).not.toHaveBeenCalled();
|
||||
|
||||
configService.getEffectiveConfig.mockResolvedValue(baseConfig(true, true));
|
||||
bookReadService.findById.mockResolvedValue(null);
|
||||
await service.scheduleIfEligible(1, 2, 'import' as never);
|
||||
expect(queueRepo.upsertSchedule).not.toHaveBeenCalled();
|
||||
|
||||
bookReadService.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: { libraryId: 2 },
|
||||
book_metadata: {
|
||||
metadataScore: 50,
|
||||
lastMetadataFetchAt: null,
|
||||
title: 'Book',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
communityRating: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
coverSource: null,
|
||||
durationSeconds: null,
|
||||
abridged: null,
|
||||
},
|
||||
},
|
||||
authorRows: [],
|
||||
genreRows: [],
|
||||
narratorRows: [],
|
||||
communityRatingRows: [],
|
||||
});
|
||||
eligibilityService.isEligible.mockReturnValue(false);
|
||||
await service.scheduleIfEligible(1, 2, 'import' as never);
|
||||
expect(queueRepo.upsertSchedule).not.toHaveBeenCalled();
|
||||
await expect(service.scheduleImportedBooksIfEligible(2, [1])).resolves.toBe(0);
|
||||
expect(queueRepo.scheduleEligibleBookIdsInBatches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requeueFailed returns zero without session update', async () => {
|
||||
|
||||
+59
-17
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy, Optional } from '@nestjs/common';
|
||||
import type { BookMetadataFetchReason, MetadataField } from '@bookorbit/types';
|
||||
import type { BookMetadataFetchReason } from '@bookorbit/types';
|
||||
import { MetadataProviderKey, NotificationType } from '@bookorbit/types';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
@@ -7,8 +7,9 @@ import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/u
|
||||
import { BookReadService } from '../book/book-read.service';
|
||||
import * as schema from '../../db/schema';
|
||||
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
|
||||
import { ComicMetadataRepository } from '../metadata/comic-metadata.repository';
|
||||
import { MetadataService } from '../metadata/metadata.service';
|
||||
import { MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
|
||||
import { ExistingMetadataFields, MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
|
||||
import { ProviderThrottleTracker } from '../metadata-fetch/provider-throttle.tracker';
|
||||
import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-search-params';
|
||||
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
|
||||
@@ -22,6 +23,8 @@ const POLL_INTERVAL_MS = 4_000;
|
||||
const BATCH_SIZE = 1;
|
||||
const SCHEDULE_BATCH_SIZE = 1000;
|
||||
|
||||
type BookReadResult = NonNullable<Awaited<ReturnType<BookReadService['findById']>>>;
|
||||
|
||||
@Injectable()
|
||||
export class BookMetadataFetchOrchestratorService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger(BookMetadataFetchOrchestratorService.name);
|
||||
@@ -36,6 +39,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
private readonly bookReadService: BookReadService,
|
||||
private readonly pipeline: MetadataFetchPipeline,
|
||||
private readonly metadataService: MetadataService,
|
||||
private readonly comicMetadataRepository: ComicMetadataRepository,
|
||||
private readonly scoreService: MetadataScoreService,
|
||||
private readonly bookMetadataLockService: BookMetadataLockService,
|
||||
private readonly session: BookMetadataFetchSessionService,
|
||||
@@ -60,20 +64,16 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
}
|
||||
}
|
||||
|
||||
async scheduleIfEligible(bookId: number, libraryId: number, reason: BookMetadataFetchReason): Promise<void> {
|
||||
async scheduleImportedBooksIfEligible(libraryId: number, bookIds: readonly number[]): Promise<number> {
|
||||
const config = await this.configService.getEffectiveConfig(libraryId);
|
||||
if (!config.enabled || !config.triggerOnImport) return;
|
||||
if (!config.enabled || !config.triggerOnImport) return 0;
|
||||
|
||||
const bookData = await this.loadEligibilityData(bookId);
|
||||
if (!bookData) return;
|
||||
|
||||
if (!this.eligibilityService.isEligible(bookData, config)) return;
|
||||
|
||||
const queued = await this.queueRepo.upsertSchedule([bookId], reason);
|
||||
const queued = await this.queueRepo.scheduleEligibleBookIdsInBatches(config, 'event_import', libraryId, bookIds, SCHEDULE_BATCH_SIZE);
|
||||
if (queued > 0) {
|
||||
this.session.addToTotal(queued);
|
||||
await this.emitStatus();
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
|
||||
async triggerGlobal(): Promise<number> {
|
||||
@@ -142,7 +142,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
if (this.paused) return;
|
||||
const dueRows = await this.queueRepo.fetchDue(BATCH_SIZE);
|
||||
if (dueRows.length > 0) {
|
||||
await this.processOne(dueRows[0].bookId, dueRows[0].title);
|
||||
await this.processOne(dueRows[0].bookId, dueRows[0].title, dueRows[0].reason);
|
||||
await this.randomDelay();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -156,7 +156,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
}
|
||||
}
|
||||
|
||||
private async processOne(bookId: number, title: string | null): Promise<void> {
|
||||
private async processOne(bookId: number, title: string | null, reason: BookMetadataFetchReason = 'manual_trigger'): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const claimed = await this.queueRepo.markProcessing(bookId);
|
||||
if (!claimed) return;
|
||||
@@ -181,6 +181,42 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
const meta = book.book_metadata;
|
||||
const libraryId = book.books.libraryId;
|
||||
|
||||
if (book.books.status === 'processing') {
|
||||
await this.queueRepo.deferProcessing(bookId);
|
||||
this.session.setCurrentItemName(null);
|
||||
this.logger.debug(
|
||||
`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} outcome=awaiting_import_completion - metadata fetch deferred`,
|
||||
);
|
||||
await this.emitStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (book.books.status !== undefined && book.books.status !== 'present') {
|
||||
await this.queueRepo.markDone(bookId);
|
||||
this.session.incrementDone();
|
||||
this.session.setCurrentItemName(null);
|
||||
this.logger.debug(
|
||||
`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} outcome=book_not_present - metadata fetch completed`,
|
||||
);
|
||||
await this.emitStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const preserveExisting = reason === 'event_import';
|
||||
if (preserveExisting) {
|
||||
const config = await this.configService.getEffectiveConfig(libraryId);
|
||||
if (!config.enabled || !config.triggerOnImport || !this.eligibilityService.isEligible(this.toEligibilityData(found), config)) {
|
||||
await this.queueRepo.markDone(bookId);
|
||||
this.session.incrementDone();
|
||||
this.session.setCurrentItemName(null);
|
||||
this.logger.debug(
|
||||
`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} outcome=event_import_no_longer_eligible - metadata fetch completed`,
|
||||
);
|
||||
await this.emitStatus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const searchParams: MetadataSearchParams = {
|
||||
title: meta?.title ?? undefined,
|
||||
author: authorRows[0]?.name ?? undefined,
|
||||
@@ -190,7 +226,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
maxCandidatesPerProvider: 1,
|
||||
};
|
||||
|
||||
const existingFields: Partial<Record<MetadataField, unknown>> = {
|
||||
const existingFields: ExistingMetadataFields = {
|
||||
title: meta?.title,
|
||||
subtitle: meta?.subtitle,
|
||||
description: meta?.description,
|
||||
@@ -207,9 +243,17 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
duration: meta?.durationSeconds ?? undefined,
|
||||
abridged: meta?.abridged ?? undefined,
|
||||
narrators: narratorRows.map((n) => n.name),
|
||||
chapters: meta?.chapters,
|
||||
hardcoverEditionId: meta?.hardcoverEditionId,
|
||||
};
|
||||
|
||||
const { resolved, providerIds } = await this.pipeline.runWithSources(searchParams, existingFields, libraryId);
|
||||
if (preserveExisting) {
|
||||
existingFields.comicMetadata = await this.comicMetadataRepository.findByBookId(bookId);
|
||||
}
|
||||
|
||||
const { resolved, providerIds } = preserveExisting
|
||||
? await this.pipeline.runWithSources(searchParams, existingFields, libraryId, { preserveExisting: true })
|
||||
: await this.pipeline.runWithSources(searchParams, existingFields, libraryId);
|
||||
|
||||
await this.persistResolved(bookId, resolved, providerIds, authorRows, genreRows, narratorRows);
|
||||
|
||||
@@ -366,9 +410,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEligibilityData(bookId: number) {
|
||||
const found = await this.bookReadService.findById(bookId);
|
||||
if (!found) return null;
|
||||
private toEligibilityData(found: BookReadResult) {
|
||||
const { book, authorRows, genreRows, narratorRows, communityRatingRows } = found;
|
||||
const meta = book.book_metadata;
|
||||
return {
|
||||
|
||||
+28
-1
@@ -11,13 +11,14 @@ vi.mock('drizzle-orm', () => {
|
||||
asc: vi.fn((value: unknown) => ({ op: 'asc', value })),
|
||||
count: vi.fn(() => ({ op: 'count' })),
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
inArray: vi.fn((left: unknown, values: unknown[]) => ({ op: 'inArray', left, values })),
|
||||
isNull: vi.fn((value: unknown) => ({ op: 'isNull', value })),
|
||||
or: vi.fn((...clauses: unknown[]) => ({ op: 'or', clauses })),
|
||||
sql: sqlFn,
|
||||
};
|
||||
});
|
||||
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import type { BookMetadataFetchConfig } from '@bookorbit/types';
|
||||
|
||||
import { bookMetadata, bookMetadataFetchQueue, bookNarrators, books } from '../../db/schema';
|
||||
@@ -265,6 +266,32 @@ describe('BookMetadataFetchQueueRepository', () => {
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedules only the imported, eligible book ids in bounded batches', async () => {
|
||||
const { db, selectBuilder } = makeDb();
|
||||
selectBuilder.where.mockResolvedValueOnce([{ bookId: 2 }, { bookId: 3 }]).mockResolvedValueOnce([{ bookId: 7 }]);
|
||||
const repo = new BookMetadataFetchQueueRepository(db as never);
|
||||
vi.spyOn(repo, 'upsertSchedule').mockResolvedValueOnce(2).mockResolvedValueOnce(1);
|
||||
const config = baseConfig();
|
||||
config.conditions.neverFetched.enabled = true;
|
||||
|
||||
await expect(repo.scheduleEligibleBookIdsInBatches(config, 'event_import', 5, [2, 3, 3, 7, -1], 2)).resolves.toBe(3);
|
||||
|
||||
expect(repo.upsertSchedule).toHaveBeenNthCalledWith(1, [2, 3], 'event_import');
|
||||
expect(repo.upsertSchedule).toHaveBeenNthCalledWith(2, [7], 'event_import');
|
||||
expect(inArray).toHaveBeenCalledWith(bookMetadata.bookId, [2, 3]);
|
||||
expect(inArray).toHaveBeenCalledWith(bookMetadata.bookId, [7]);
|
||||
});
|
||||
|
||||
it('defers a processing row until import completion', async () => {
|
||||
const { db, updateBuilder } = makeDb();
|
||||
const repo = new BookMetadataFetchQueueRepository(db as never);
|
||||
|
||||
await expect(repo.deferProcessing(18)).resolves.toBeUndefined();
|
||||
|
||||
expect(updateBuilder.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'queued' }));
|
||||
expect(and).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq' }), expect.objectContaining({ op: 'eq', right: 'processing' }));
|
||||
});
|
||||
|
||||
it('getFailedItems maps nullable fields and returns total count', async () => {
|
||||
const failedRows = [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
MetadataField,
|
||||
} from '@bookorbit/types';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { and, asc, count, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import { and, asc, count, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -68,12 +68,13 @@ export class BookMetadataFetchQueueRepository {
|
||||
return touched.length;
|
||||
}
|
||||
|
||||
async fetchDue(limit: number): Promise<{ bookId: number; title: string | null }[]> {
|
||||
async fetchDue(limit: number): Promise<{ bookId: number; title: string | null; reason: BookMetadataFetchReason }[]> {
|
||||
if (limit <= 0) return [];
|
||||
return this.db
|
||||
const rows = await this.db
|
||||
.select({
|
||||
bookId: bookMetadataFetchQueue.bookId,
|
||||
title: sql<string | null>`COALESCE(${bookMetadata.title}, ${books.folderPath})`,
|
||||
reason: bookMetadataFetchQueue.reason,
|
||||
})
|
||||
.from(bookMetadataFetchQueue)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookMetadataFetchQueue.bookId))
|
||||
@@ -81,6 +82,8 @@ export class BookMetadataFetchQueueRepository {
|
||||
.where(eq(bookMetadataFetchQueue.status, QUEUE_STATUS.QUEUED))
|
||||
.orderBy(asc(bookMetadataFetchQueue.createdAt), asc(bookMetadataFetchQueue.bookId))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => ({ ...row, reason: row.reason as BookMetadataFetchReason }));
|
||||
}
|
||||
|
||||
async markProcessing(bookId: number): Promise<boolean> {
|
||||
@@ -115,6 +118,13 @@ export class BookMetadataFetchQueueRepository {
|
||||
.where(eq(bookMetadataFetchQueue.bookId, bookId));
|
||||
}
|
||||
|
||||
async deferProcessing(bookId: number): Promise<void> {
|
||||
await this.db
|
||||
.update(bookMetadataFetchQueue)
|
||||
.set({ status: QUEUE_STATUS.QUEUED, updatedAt: new Date() })
|
||||
.where(and(eq(bookMetadataFetchQueue.bookId, bookId), eq(bookMetadataFetchQueue.status, QUEUE_STATUS.PROCESSING)));
|
||||
}
|
||||
|
||||
async cancelPending(): Promise<number> {
|
||||
const deleted = await this.db
|
||||
.delete(bookMetadataFetchQueue)
|
||||
@@ -220,6 +230,35 @@ export class BookMetadataFetchQueueRepository {
|
||||
return totalQueued;
|
||||
}
|
||||
|
||||
async scheduleEligibleBookIdsInBatches(
|
||||
config: BookMetadataFetchConfig,
|
||||
reason: BookMetadataFetchReason,
|
||||
libraryId: number,
|
||||
bookIds: readonly number[],
|
||||
batchSize = 1000,
|
||||
): Promise<number> {
|
||||
const ids = [...new Set(bookIds)].filter((id) => Number.isInteger(id) && id > 0);
|
||||
const whereClause = this.buildEligibleBooksWhereClause(config, libraryId);
|
||||
if (!whereClause || ids.length === 0 || batchSize <= 0) return 0;
|
||||
|
||||
let totalQueued = 0;
|
||||
for (let offset = 0; offset < ids.length; offset += batchSize) {
|
||||
const batch = ids.slice(offset, offset + batchSize);
|
||||
const rows = await this.db
|
||||
.select({ bookId: bookMetadata.bookId })
|
||||
.from(bookMetadata)
|
||||
.innerJoin(books, eq(books.id, bookMetadata.bookId))
|
||||
.where(and(whereClause, inArray(bookMetadata.bookId, batch)));
|
||||
|
||||
totalQueued += await this.upsertSchedule(
|
||||
rows.map((row) => row.bookId),
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
return totalQueued;
|
||||
}
|
||||
|
||||
async countEligibleBooks(config: BookMetadataFetchConfig, libraryId?: number): Promise<number> {
|
||||
const whereClause = this.buildEligibleBooksWhereClause(config, libraryId);
|
||||
if (!whereClause) return 0;
|
||||
|
||||
@@ -370,6 +370,138 @@ describe('MetadataFetchPipeline', () => {
|
||||
expect(sources.coverUrl).toBe(MetadataProviderKey.OPEN_LIBRARY);
|
||||
});
|
||||
|
||||
it('does not replace an existing cover when the field rule is fillMissing', async () => {
|
||||
const prefs = createPreferences((fields) => {
|
||||
fields.cover = {
|
||||
enabled: true,
|
||||
providers: [MetadataProviderKey.OPEN_LIBRARY],
|
||||
mergeStrategy: 'fillMissing',
|
||||
};
|
||||
});
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
resolver.resolve.mockReturnValue(prefs);
|
||||
resolver.withForwardCompatibility.mockReturnValue(prefs);
|
||||
registry.all.mockReturnValue([{ key: MetadataProviderKey.OPEN_LIBRARY }] as never);
|
||||
fetchService.search.mockReturnValue(of(candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', { coverUrl: 'https://img.example/cover.jpg' })));
|
||||
|
||||
const { resolved, sources } = await pipeline.runWithSources({ title: 'Query' }, { cover: 'extracted' });
|
||||
|
||||
expect(resolved.coverUrl).toBeUndefined();
|
||||
expect(sources.coverUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves imported metadata while filling missing values during event-import enrichment', async () => {
|
||||
const prefs = createPreferences((fields) => {
|
||||
fields.title = {
|
||||
enabled: true,
|
||||
providers: [MetadataProviderKey.GOOGLE],
|
||||
mergeStrategy: 'overwriteIfProvided',
|
||||
};
|
||||
fields.authors = {
|
||||
enabled: true,
|
||||
providers: [MetadataProviderKey.GOOGLE],
|
||||
mergeStrategy: 'overwriteIfProvided',
|
||||
};
|
||||
fields.description = {
|
||||
enabled: true,
|
||||
providers: [MetadataProviderKey.GOOGLE],
|
||||
mergeStrategy: 'overwriteIfProvided',
|
||||
};
|
||||
fields.genres = {
|
||||
enabled: true,
|
||||
providers: [MetadataProviderKey.GOOGLE],
|
||||
mergeStrategy: 'overwriteIfProvided',
|
||||
};
|
||||
fields.cover = {
|
||||
enabled: true,
|
||||
providers: [MetadataProviderKey.GOOGLE],
|
||||
mergeStrategy: 'overwriteIfProvided',
|
||||
};
|
||||
});
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
resolver.resolve.mockReturnValue(prefs);
|
||||
resolver.withForwardCompatibility.mockReturnValue(prefs);
|
||||
registry.all.mockReturnValue([{ key: MetadataProviderKey.GOOGLE }] as never);
|
||||
fetchService.search.mockReturnValue(
|
||||
of(
|
||||
candidate(MetadataProviderKey.GOOGLE, 'g1', {
|
||||
title: 'Provider Title',
|
||||
authors: ['Provider Author'],
|
||||
description: 'Provider Description',
|
||||
genres: ['Provider Genre'],
|
||||
coverUrl: 'https://img.example/provider-cover.jpg',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { resolved } = await pipeline.runWithSources(
|
||||
{ title: 'File Title' },
|
||||
{
|
||||
title: 'File Title',
|
||||
authors: ['File Author'],
|
||||
description: null,
|
||||
genres: ['File Genre'],
|
||||
cover: 'extracted',
|
||||
},
|
||||
undefined,
|
||||
{ preserveExisting: true },
|
||||
);
|
||||
|
||||
expect(resolved).toMatchObject({ description: 'Provider Description' });
|
||||
expect(resolved.title).toBeUndefined();
|
||||
expect(resolved.authors).toBeUndefined();
|
||||
expect(resolved.genres).toBeUndefined();
|
||||
expect(resolved.coverUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves populated ComicInfo fields while filling missing comic metadata during event-import enrichment', async () => {
|
||||
const prefs = createPreferences((fields) => {
|
||||
fields.title = {
|
||||
enabled: true,
|
||||
providers: [MetadataProviderKey.COMICVINE],
|
||||
mergeStrategy: 'overwriteIfProvided',
|
||||
};
|
||||
fields.seriesName = {
|
||||
enabled: true,
|
||||
providers: [MetadataProviderKey.COMICVINE],
|
||||
mergeStrategy: 'overwriteIfProvided',
|
||||
};
|
||||
});
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
resolver.resolve.mockReturnValue(prefs);
|
||||
resolver.withForwardCompatibility.mockReturnValue(prefs);
|
||||
registry.all.mockReturnValue([{ key: MetadataProviderKey.COMICVINE }] as never);
|
||||
fetchService.search.mockReturnValue(
|
||||
of(
|
||||
candidate(MetadataProviderKey.COMICVINE, 'cv1', {
|
||||
comicMetadata: {
|
||||
issueNumber: '99',
|
||||
volumeName: 'Provider Volume',
|
||||
pencillers: ['Provider Penciller'],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { resolved } = await pipeline.runWithSources(
|
||||
{ title: 'Akira' },
|
||||
{
|
||||
comicMetadata: {
|
||||
issueNumber: '28',
|
||||
volumeName: null,
|
||||
pencillers: ['Katsuhiro Otomo'],
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ preserveExisting: true },
|
||||
);
|
||||
|
||||
expect(resolved.comicMetadata).toEqual({ volumeName: 'Provider Volume' });
|
||||
});
|
||||
|
||||
it('passes through comic metadata from the preferred provider', async () => {
|
||||
const prefs = createPreferences((fields) => {
|
||||
fields.title = {
|
||||
|
||||
@@ -33,6 +33,17 @@ export type ResolvedMetadataFields = Partial<Record<MetadataField, string | stri
|
||||
comicMetadata?: ComicMetadataFields;
|
||||
communityRatings?: BookCommunityRating[];
|
||||
};
|
||||
|
||||
export interface ExistingMetadataFields extends Partial<Record<MetadataField, unknown>> {
|
||||
chapters?: unknown;
|
||||
comicMetadata?: Partial<Record<keyof ComicMetadataFields, unknown>> | null;
|
||||
hardcoverEditionId?: unknown;
|
||||
}
|
||||
|
||||
export interface MetadataFetchRunOptions {
|
||||
preserveExisting?: boolean;
|
||||
}
|
||||
|
||||
type ResolvedProviderIds = Partial<Record<MetadataProviderKey, string>>;
|
||||
|
||||
type ProviderSelectionDiagnostics = Pick<
|
||||
@@ -68,24 +79,26 @@ export class MetadataFetchPipeline {
|
||||
|
||||
async run(
|
||||
params: MetadataSearchParams,
|
||||
existingFields: Partial<Record<MetadataField, unknown>>,
|
||||
existingFields: ExistingMetadataFields,
|
||||
libraryId?: number,
|
||||
options?: MetadataFetchRunOptions,
|
||||
): Promise<ResolvedMetadataFields> {
|
||||
const { resolved } = await this.runInternal(params, existingFields, libraryId);
|
||||
const { resolved } = await this.runInternal(params, existingFields, libraryId, options);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async runWithSources(
|
||||
params: MetadataSearchParams,
|
||||
existingFields: Partial<Record<MetadataField, unknown>>,
|
||||
existingFields: ExistingMetadataFields,
|
||||
libraryId?: number,
|
||||
options?: MetadataFetchRunOptions,
|
||||
): Promise<{
|
||||
resolved: ResolvedMetadataFields;
|
||||
sources: Record<string, string>;
|
||||
providerIds: ResolvedProviderIds;
|
||||
diagnostics: MetadataFetchDiagnostics;
|
||||
}> {
|
||||
return this.runInternal(params, existingFields, libraryId);
|
||||
return this.runInternal(params, existingFields, libraryId, options);
|
||||
}
|
||||
|
||||
async getEffectiveProviderKeys(libraryId?: number): Promise<MetadataProviderKey[]> {
|
||||
@@ -95,8 +108,9 @@ export class MetadataFetchPipeline {
|
||||
|
||||
private async runInternal(
|
||||
params: MetadataSearchParams,
|
||||
existingFields: Partial<Record<MetadataField, unknown>>,
|
||||
existingFields: ExistingMetadataFields,
|
||||
libraryId?: number,
|
||||
options?: MetadataFetchRunOptions,
|
||||
): Promise<{
|
||||
resolved: ResolvedMetadataFields;
|
||||
sources: Record<string, string>;
|
||||
@@ -115,7 +129,7 @@ export class MetadataFetchPipeline {
|
||||
for (const c of candidates) {
|
||||
if (!byProvider.has(c.provider)) byProvider.set(c.provider, c);
|
||||
}
|
||||
const { resolved, sources, providerIds } = this.applyPreferences(preferences, byProvider, existingFields);
|
||||
const { resolved, sources, providerIds } = this.applyPreferences(preferences, byProvider, existingFields, params.existingProviderIds, options);
|
||||
const diagnostics = this.buildDiagnostics(providerSelection, candidates, resolved);
|
||||
return { resolved, sources, providerIds, diagnostics };
|
||||
}
|
||||
@@ -224,7 +238,9 @@ export class MetadataFetchPipeline {
|
||||
private applyPreferences(
|
||||
preferences: MetadataFetchPreferences,
|
||||
byProvider: Map<string, MetadataCandidate>,
|
||||
existing: Partial<Record<MetadataField, unknown>>,
|
||||
existing: ExistingMetadataFields,
|
||||
existingProviderIds: Partial<Record<MetadataProviderKey, string>> | undefined,
|
||||
options: MetadataFetchRunOptions | undefined,
|
||||
): { resolved: ResolvedMetadataFields; sources: Record<string, string>; providerIds: ResolvedProviderIds } {
|
||||
const result: ResolvedMetadataFields = {};
|
||||
const sources: Record<string, string> = {};
|
||||
@@ -233,13 +249,14 @@ export class MetadataFetchPipeline {
|
||||
for (const field of Object.keys(preferences.fields) as MetadataField[]) {
|
||||
const fieldPreference = preferences.fields[field];
|
||||
if (!fieldPreference.enabled) continue;
|
||||
const mergeStrategy = options?.preserveExisting ? 'fillMissing' : fieldPreference.mergeStrategy;
|
||||
|
||||
if (field === 'genres' && preferences.options?.genres.mode === 'merge') {
|
||||
const { genres, sourceProvider } = this.mergeGenres(fieldPreference.providers as MetadataProviderKey[], byProvider, blockedGenreTokens);
|
||||
if (!genres.length) continue;
|
||||
|
||||
const existingValue = existing[field];
|
||||
switch (fieldPreference.mergeStrategy) {
|
||||
switch (mergeStrategy) {
|
||||
case 'fillMissing':
|
||||
if (this.isMissing(existingValue)) {
|
||||
result.genres = genres;
|
||||
@@ -257,7 +274,7 @@ export class MetadataFetchPipeline {
|
||||
|
||||
if (field === 'communityRating') {
|
||||
const existingValue = existing[field];
|
||||
if (fieldPreference.mergeStrategy === 'fillMissing' && !this.isMissing(existingValue)) continue;
|
||||
if (mergeStrategy === 'fillMissing' && !this.isMissing(existingValue)) continue;
|
||||
|
||||
const communityRatings = this.collectCommunityRatings(fieldPreference.providers as MetadataProviderKey[], byProvider);
|
||||
if (communityRatings.length > 0) {
|
||||
@@ -275,6 +292,8 @@ export class MetadataFetchPipeline {
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
if (field === 'cover') {
|
||||
const existingValue = existing[field];
|
||||
if (mergeStrategy === 'fillMissing' && !this.isMissing(existingValue)) break;
|
||||
result.coverUrl = candidate.coverUrl;
|
||||
sources['coverUrl'] = providerKey;
|
||||
break;
|
||||
@@ -288,7 +307,7 @@ export class MetadataFetchPipeline {
|
||||
}
|
||||
|
||||
const existingValue = existing[field];
|
||||
switch (fieldPreference.mergeStrategy) {
|
||||
switch (mergeStrategy) {
|
||||
case 'fillMissing':
|
||||
if (this.isMissing(existingValue)) {
|
||||
(result as Record<string, unknown>)[field] = value;
|
||||
@@ -313,8 +332,14 @@ export class MetadataFetchPipeline {
|
||||
const providerIds: ResolvedProviderIds = {};
|
||||
if (preferences.options?.saveProviderIds) {
|
||||
for (const candidate of byProvider.values()) {
|
||||
if (candidate.providerId) providerIds[candidate.provider] = candidate.providerId;
|
||||
if (candidate.provider === MetadataProviderKey.HARDCOVER && candidate.hardcoverEditionId) {
|
||||
if (candidate.providerId && (!options?.preserveExisting || !existingProviderIds?.[candidate.provider])) {
|
||||
providerIds[candidate.provider] = candidate.providerId;
|
||||
}
|
||||
if (
|
||||
candidate.provider === MetadataProviderKey.HARDCOVER &&
|
||||
candidate.hardcoverEditionId &&
|
||||
(!options?.preserveExisting || this.isMissing(existing.hardcoverEditionId))
|
||||
) {
|
||||
result.hardcoverEditionId = candidate.hardcoverEditionId;
|
||||
}
|
||||
}
|
||||
@@ -326,14 +351,15 @@ export class MetadataFetchPipeline {
|
||||
const chapterProviders = [...narratorProviders, ...byProvider.keys()];
|
||||
for (const providerKey of chapterProviders) {
|
||||
const candidate = byProvider.get(providerKey);
|
||||
if (candidate?.chapters?.length) {
|
||||
if (candidate?.chapters?.length && (!options?.preserveExisting || this.isMissing(existing.chapters))) {
|
||||
result.chapters = candidate.chapters;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const comicMetadata = this.resolveComicMetadata(preferences, byProvider);
|
||||
if (comicMetadata) result.comicMetadata = comicMetadata;
|
||||
const filteredComicMetadata = options?.preserveExisting ? this.filterExistingComicMetadata(comicMetadata, existing.comicMetadata) : comicMetadata;
|
||||
if (filteredComicMetadata) result.comicMetadata = filteredComicMetadata;
|
||||
|
||||
return { resolved: result, sources, providerIds };
|
||||
}
|
||||
@@ -461,6 +487,21 @@ export class MetadataFetchPipeline {
|
||||
return false;
|
||||
}
|
||||
|
||||
private filterExistingComicMetadata(
|
||||
resolved: ComicMetadataFields | undefined,
|
||||
existing: Partial<Record<keyof ComicMetadataFields, unknown>> | null | undefined,
|
||||
): ComicMetadataFields | undefined {
|
||||
if (!resolved || !existing) return resolved;
|
||||
|
||||
const filtered: ComicMetadataFields = {};
|
||||
for (const [key, value] of Object.entries(resolved) as [keyof ComicMetadataFields, ComicMetadataFields[keyof ComicMetadataFields]][]) {
|
||||
if (value === undefined || !this.isMissing(existing[key])) continue;
|
||||
filtered[key] = value as never;
|
||||
}
|
||||
|
||||
return this.hasComicMetadata(filtered) ? filtered : undefined;
|
||||
}
|
||||
|
||||
private resolveComicMetadata(preferences: MetadataFetchPreferences, byProvider: Map<string, MetadataCandidate>): ComicMetadataFields | undefined {
|
||||
const preferredProviders = [
|
||||
...(preferences.fields.seriesName?.providers ?? []),
|
||||
|
||||
@@ -145,7 +145,10 @@ const mockMetadata = {
|
||||
extractAudioChaptersAndNarrators: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
function makeService(repo: ReturnType<typeof makeRepo>) {
|
||||
function makeService(
|
||||
repo: ReturnType<typeof makeRepo>,
|
||||
autoFetchOrchestrator?: { scheduleImportedBooksIfEligible: (...args: unknown[]) => Promise<number> },
|
||||
) {
|
||||
const jobStore = new ScanJobStore();
|
||||
const notificationService = { notify: vi.fn().mockResolvedValue(undefined) };
|
||||
const achievementEvents = { emit: vi.fn() };
|
||||
@@ -155,7 +158,7 @@ function makeService(repo: ReturnType<typeof makeRepo>) {
|
||||
jobStore,
|
||||
mockGateway as any,
|
||||
notificationService as any,
|
||||
undefined,
|
||||
autoFetchOrchestrator as any,
|
||||
achievementEvents as any,
|
||||
);
|
||||
return { service, jobStore, notificationService, achievementEvents };
|
||||
@@ -592,6 +595,37 @@ describe('genuinely new primary file', () => {
|
||||
expect(mockMetadata.extractAndSave).toHaveBeenCalledWith(expect.any(Number), '/library/Author/Book/book.epub', 'epub');
|
||||
});
|
||||
|
||||
it('queues import metadata fetch only after local metadata extraction and book promotion', async () => {
|
||||
const candidate = makeCandidate('/library/Author/Book', [makeFileStat()]);
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
|
||||
const order: string[] = [];
|
||||
mockMetadata.extractAndSave.mockImplementation(() => {
|
||||
order.push('extract');
|
||||
return Promise.resolve();
|
||||
});
|
||||
const repo = makeRepo({
|
||||
promoteProcessingBookToPresent: vi.fn().mockImplementation(() => {
|
||||
order.push('promote');
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
});
|
||||
const autoFetchOrchestrator = {
|
||||
scheduleImportedBooksIfEligible: vi.fn().mockImplementation(() => {
|
||||
order.push('schedule');
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
};
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo, autoFetchOrchestrator);
|
||||
|
||||
await service.startScan(1, 'manual');
|
||||
await done;
|
||||
|
||||
expect(order).toEqual(['extract', 'promote', 'schedule']);
|
||||
expect(autoFetchOrchestrator.scheduleImportedBooksIfEligible).toHaveBeenCalledWith(1, [1]);
|
||||
});
|
||||
|
||||
it('does not extract metadata for non-primary files', async () => {
|
||||
const primary = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub' });
|
||||
const cover = makeFileStat({ absolutePath: '/library/Book/cover.jpg', relPath: 'Book/cover.jpg', sizeBytes: 512 });
|
||||
|
||||
@@ -140,6 +140,19 @@ interface ProcessedFileResult {
|
||||
fileId: number | null;
|
||||
}
|
||||
|
||||
interface UpsertBookResult extends BookEntry {
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
interface ProcessCandidateResult {
|
||||
bookId: number;
|
||||
added: number;
|
||||
updated: number;
|
||||
retainedFileIds: Set<number>;
|
||||
becameVisible: boolean;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
function normalizeOrganizationMode(mode: string | null | undefined): OrganizationMode {
|
||||
return mode === 'book_per_file' ? 'book_per_file' : 'book_per_folder';
|
||||
}
|
||||
@@ -858,6 +871,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
const totals = { added: 0, updated: 0 };
|
||||
const allRetainedFileIds = new Set<number>();
|
||||
const seenBookIds = new Set<number>();
|
||||
const importedBookIds: number[] = [];
|
||||
const candidateFolderPaths = new Set(candidates.map((candidate) => candidate.folderPath));
|
||||
for (const candidate of candidates) {
|
||||
const result = await this.processCandidate(
|
||||
@@ -872,6 +886,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
);
|
||||
this.emitTargetedScanResult(libraryId, result);
|
||||
seenBookIds.add(result.bookId);
|
||||
if (result.created) importedBookIds.push(result.bookId);
|
||||
for (const fid of result.retainedFileIds) allRetainedFileIds.add(fid);
|
||||
totals.added += result.added;
|
||||
totals.updated += result.updated;
|
||||
@@ -882,6 +897,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
await this.pruneMissingBookFiles(bookId, allRetainedFileIds, maps.fileIdsByBookId, maps.fileByPath, maps.fileByIno, pruneCounts);
|
||||
}
|
||||
totals.updated += pruneCounts.updated;
|
||||
await this.scheduleImportedBookMetadataFetch(libraryId, importedBookIds);
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(dirPath)}" durationMs=${Date.now() - startedAt} candidateCount=${candidates.length} skippedDirCount=${skippedDirs.size} added=${totals.added} updated=${totals.updated} - targeted directory scan completed`,
|
||||
@@ -978,6 +994,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
updated: 0,
|
||||
});
|
||||
this.emitTargetedScanResult(libraryId, result);
|
||||
await this.scheduleImportedBookMetadataFetch(libraryId, result.created ? [result.bookId] : []);
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} added=${result.added} updated=${result.updated} scanScope=file - targeted book scan completed`,
|
||||
);
|
||||
@@ -1046,6 +1063,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
updated: 0,
|
||||
});
|
||||
this.emitTargetedScanResult(libraryId, result);
|
||||
await this.scheduleImportedBookMetadataFetch(libraryId, result.created ? [result.bookId] : []);
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} added=${result.added} updated=${result.updated} scanScope=root_file - targeted book scan completed`,
|
||||
);
|
||||
@@ -1134,6 +1152,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
updated: 0,
|
||||
});
|
||||
this.emitTargetedScanResult(libraryId, result);
|
||||
await this.scheduleImportedBookMetadataFetch(libraryId, result.created ? [result.bookId] : []);
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} scanScope=folder folder="${sanitizeLogValue(basename(resolvedBookFolder))}" added=${result.added} updated=${result.updated} - targeted book scan completed`,
|
||||
);
|
||||
@@ -1325,6 +1344,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
const seenBookIds = new Set<number>();
|
||||
const allRetainedFileIds = new Set<number>();
|
||||
const importedBookIds: number[] = [];
|
||||
const candidateFolderPaths = new Set(candidates.map((candidate) => candidate.folderPath));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -1339,6 +1359,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
candidateFolderPaths,
|
||||
);
|
||||
seenBookIds.add(result.bookId);
|
||||
if (result.created) importedBookIds.push(result.bookId);
|
||||
for (const fid of result.retainedFileIds) allRetainedFileIds.add(fid);
|
||||
counts.addedCount += result.added;
|
||||
counts.updatedCount += result.updated;
|
||||
@@ -1390,6 +1411,8 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
this.bufferBooksUnavailableNotification(libraryId, missingIds);
|
||||
}
|
||||
|
||||
await this.scheduleImportedBookMetadataFetch(libraryId, importedBookIds);
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} jobId=${jobId} libraryFolderId=${libraryFolderId} durationMs=${Date.now() - startedAt} addedCount=${counts.addedCount} updatedCount=${counts.updatedCount} missingCount=${counts.missingCount} - folder candidate scan completed`,
|
||||
);
|
||||
@@ -1413,7 +1436,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
metadataPrecedence: string[],
|
||||
isFirstScan: boolean,
|
||||
candidateFolderPaths: Set<string>,
|
||||
): Promise<{ bookId: number; added: number; updated: number; retainedFileIds: Set<number>; becameVisible: boolean }> {
|
||||
): Promise<ProcessCandidateResult> {
|
||||
const { bookByFolderPath, booksByParentDir, fileByPath, fileByIno } = maps;
|
||||
const counts = { added: 0, updated: 0 };
|
||||
const fileCounts: ScanCounts = { addedCount: 0, updatedCount: 0, missingCount: 0 };
|
||||
@@ -1565,7 +1588,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
const becameVisible = await this.scannerRepo.promoteProcessingBookToPresent(book.id);
|
||||
return { bookId: book.id, ...counts, retainedFileIds, becameVisible };
|
||||
return { bookId: book.id, ...counts, retainedFileIds, becameVisible, created: book.created };
|
||||
}
|
||||
|
||||
private buildMetadataExtractionSources(
|
||||
@@ -1643,7 +1666,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
counts: ScanCounts,
|
||||
candidateFolderPaths: Set<string>,
|
||||
) {
|
||||
): Promise<UpsertBookResult> {
|
||||
const existing = bookByFolderPath.get(candidate.folderPath);
|
||||
const candidateOwnedBookIds = new Set<number>();
|
||||
for (const file of candidate.files) {
|
||||
@@ -1682,14 +1705,14 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
this.logger.log(
|
||||
`[scanner.upsert_book] [end] libraryId=${libraryId} bookId=${survivor.id} folder="${sanitizeLogValue(candidate.folderPath)}" mergedCount=${virtualChildren.length} action=merge_stem_split - stem-split books merged`,
|
||||
);
|
||||
return { ...survivor, folderPath: candidate.folderPath };
|
||||
return { ...survivor, folderPath: candidate.folderPath, created: false };
|
||||
}
|
||||
|
||||
const movedInLibrary = await this.tryReuseMovedBookInLibrary(candidate, libraryId, bookByFolderPath, fileByPath, fileByIno, counts);
|
||||
if (movedInLibrary) return movedInLibrary;
|
||||
if (movedInLibrary) return { ...movedInLibrary, created: false };
|
||||
|
||||
const transferred = await this.tryTransferMissingBook(candidate, libraryId, libraryFolderId, bookByFolderPath, counts);
|
||||
if (transferred) return transferred;
|
||||
if (transferred) return { ...transferred, created: false };
|
||||
|
||||
const book = await this.scannerRepo.createBook({
|
||||
libraryId,
|
||||
@@ -1698,16 +1721,9 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
status: 'processing',
|
||||
});
|
||||
counts.addedCount++;
|
||||
this.autoFetchOrchestrator
|
||||
?.scheduleIfEligible(book.id, libraryId, 'event_import')
|
||||
.catch((err: Error) =>
|
||||
this.logger.warn(
|
||||
`[scanner.upsert_book] [fail] libraryId=${libraryId} bookId=${book.id} action=schedule_metadata_fetch errorClass=${err.name} error="${sanitizeLogValue(err.message)}" - metadata fetch schedule failed`,
|
||||
),
|
||||
);
|
||||
const entry = { id: book.id, status: book.status, folderPath: book.folderPath, primaryFileId: book.primaryFileId ?? null };
|
||||
bookByFolderPath.set(candidate.folderPath, entry);
|
||||
return entry;
|
||||
return { ...entry, created: true };
|
||||
}
|
||||
|
||||
if (existing.status === 'missing') {
|
||||
@@ -1733,7 +1749,28 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
return existing;
|
||||
return { ...existing, created: false };
|
||||
}
|
||||
|
||||
private async scheduleImportedBookMetadataFetch(libraryId: number, bookIds: readonly number[]): Promise<void> {
|
||||
const ids = [...new Set(bookIds)].filter((bookId) => Number.isInteger(bookId) && bookId > 0);
|
||||
if (ids.length === 0 || !this.autoFetchOrchestrator) return;
|
||||
|
||||
const event = 'scanner.schedule_metadata_fetch';
|
||||
const startedAt = Date.now();
|
||||
this.logger.debug(`[${event}] [start] libraryId=${libraryId} bookCount=${ids.length} - metadata fetch scheduling started`);
|
||||
try {
|
||||
const queued = await this.autoFetchOrchestrator.scheduleImportedBooksIfEligible(libraryId, ids);
|
||||
this.logger.debug(
|
||||
`[${event}] [end] libraryId=${libraryId} durationMs=${Date.now() - startedAt} bookCount=${ids.length} queued=${queued} - metadata fetch scheduling 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] libraryId=${libraryId} durationMs=${Date.now() - startedAt} bookCount=${ids.length} errorClass=${errorClass} error="${errorMessage}" - metadata fetch scheduling failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async tryReuseMovedBookInLibrary(
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('UploadProcessorService', () => {
|
||||
extractAndAggregateAudioDuration: vi.fn(),
|
||||
};
|
||||
const orchestrator = {
|
||||
scheduleIfEligible: vi.fn(),
|
||||
scheduleImportedBooksIfEligible: vi.fn(),
|
||||
};
|
||||
|
||||
const insertBooksReturning = vi.fn();
|
||||
@@ -74,7 +74,7 @@ describe('UploadProcessorService', () => {
|
||||
updateBooksSet.mockReturnValue({ where: updateBooksWhere });
|
||||
updateBooksWhere.mockResolvedValue(undefined);
|
||||
|
||||
orchestrator.scheduleIfEligible.mockResolvedValue(undefined);
|
||||
orchestrator.scheduleImportedBooksIfEligible.mockResolvedValue(0);
|
||||
|
||||
mockStat.mockResolvedValue({ ino: 111n, mtime: new Date('2024-01-01') } as Awaited<ReturnType<typeof stat>>);
|
||||
mockComputeFileHash.mockResolvedValue('hash-abc');
|
||||
@@ -85,7 +85,7 @@ describe('UploadProcessorService', () => {
|
||||
it('creates book, metadata, and content file rows with fingerprint/stat data in a transaction', async () => {
|
||||
const result = await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
|
||||
|
||||
expect(result).toEqual({ bookId: 42 });
|
||||
expect(result).toEqual({ bookId: 42, created: true });
|
||||
expect(db.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(insertBooksValues).toHaveBeenCalledWith({ libraryId: 1, libraryFolderId: 2, folderPath: '/folder', status: 'present' });
|
||||
expect(insertBookMetadataValues).toHaveBeenCalledWith({ bookId: 42 });
|
||||
@@ -103,7 +103,7 @@ describe('UploadProcessorService', () => {
|
||||
}),
|
||||
);
|
||||
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: 420 });
|
||||
expect(orchestrator.scheduleIfEligible).toHaveBeenCalledWith(42, 1, 'event_import');
|
||||
expect(orchestrator.scheduleImportedBooksIfEligible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds a file to an existing book when the folder path already exists in the library', async () => {
|
||||
@@ -111,7 +111,7 @@ describe('UploadProcessorService', () => {
|
||||
|
||||
const result = await service.createBookRecord(1, 2, '/folder', '/folder/book2.epub', 'book/book2.epub', 'epub', 5000);
|
||||
|
||||
expect(result).toEqual({ bookId: 99 });
|
||||
expect(result).toEqual({ bookId: 99, created: false });
|
||||
expect(insertBooksValues).not.toHaveBeenCalled();
|
||||
expect(insertBookMetadataValues).not.toHaveBeenCalled();
|
||||
expect(updateBooksSet).not.toHaveBeenCalled();
|
||||
@@ -131,7 +131,7 @@ describe('UploadProcessorService', () => {
|
||||
set: expect.objectContaining({ bookId: 99, libraryFolderId: 2, format: 'epub' }),
|
||||
}),
|
||||
);
|
||||
expect(orchestrator.scheduleIfEligible).toHaveBeenCalledWith(99, 1, 'event_import');
|
||||
expect(orchestrator.scheduleImportedBooksIfEligible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps oversized MergerFS inode values to 0 before persisting', async () => {
|
||||
@@ -179,7 +179,7 @@ describe('UploadProcessorService', () => {
|
||||
|
||||
const result = await service.createBookRecord(1, 2, '/folder', '/folder/stale.epub', 'book/stale.epub', 'epub', 1000);
|
||||
|
||||
expect(result).toEqual({ bookId: 99 });
|
||||
expect(result).toEqual({ bookId: 99, created: false });
|
||||
expect(insertBookFilesOnConflict).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -208,12 +208,28 @@ describe('UploadProcessorService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('logs but suppresses scheduling failures', async () => {
|
||||
const warn = vi.spyOn((service as unknown as { logger: { warn: (m: string) => void } }).logger, 'warn').mockImplementation();
|
||||
orchestrator.scheduleIfEligible.mockRejectedValue(new Error('queue offline'));
|
||||
it('extracts local metadata before queueing an import fetch', async () => {
|
||||
const order: string[] = [];
|
||||
metadataService.extractAndSave.mockImplementation(() => {
|
||||
order.push('extract');
|
||||
return Promise.resolve();
|
||||
});
|
||||
orchestrator.scheduleImportedBooksIfEligible.mockImplementation(() => {
|
||||
order.push('schedule');
|
||||
return Promise.resolve(1);
|
||||
});
|
||||
|
||||
await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await (service as any).runNewBookImport(42, 1, '/folder/book.cbz', 'cbz');
|
||||
|
||||
expect(order).toEqual(['extract', 'schedule']);
|
||||
expect(orchestrator.scheduleImportedBooksIfEligible).toHaveBeenCalledWith(1, [42]);
|
||||
});
|
||||
|
||||
it('logs but suppresses post-extraction scheduling failures', async () => {
|
||||
const warn = vi.spyOn((service as unknown as { logger: { warn: (m: string) => void } }).logger, 'warn').mockImplementation();
|
||||
orchestrator.scheduleImportedBooksIfEligible.mockRejectedValue(new Error('queue offline'));
|
||||
|
||||
await (service as any).runNewBookImport(42, 1, '/folder/book.epub', 'epub');
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('queue offline'));
|
||||
});
|
||||
@@ -300,8 +316,8 @@ describe('UploadProcessorService', () => {
|
||||
it('createBookRecord completes without scheduling when orchestrator is undefined', async () => {
|
||||
const result = await serviceNoOrch.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
|
||||
|
||||
expect(result).toEqual({ bookId: 42 });
|
||||
expect(orchestrator.scheduleIfEligible).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ bookId: 42, created: true });
|
||||
expect(orchestrator.scheduleImportedBooksIfEligible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extractMetadataAsync handles supported format boundary', () => {
|
||||
|
||||
@@ -52,11 +52,11 @@ export class UploadProcessorService {
|
||||
relPath: string,
|
||||
format: string,
|
||||
sizeBytes: number,
|
||||
): Promise<{ bookId: number }> {
|
||||
): Promise<{ bookId: number; created: boolean }> {
|
||||
const [fileStat, fileHash] = await Promise.all([stat(absolutePath, { bigint: true }), computeFileHash(absolutePath)]);
|
||||
const safeIno = clampIno(fileStat.ino);
|
||||
|
||||
const { bookId } = await this.db.transaction(async (tx) => {
|
||||
const result = await this.db.transaction(async (tx) => {
|
||||
const [existingBook] = await tx
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
@@ -85,7 +85,7 @@ export class UploadProcessorService {
|
||||
})
|
||||
.returning({ id: bookFiles.id });
|
||||
if (!file) throw new InternalServerErrorException('Failed to create book file');
|
||||
return { bookId: existingBook.id };
|
||||
return { bookId: existingBook.id, created: false };
|
||||
}
|
||||
|
||||
const [book] = await tx.insert(books).values({ libraryId, libraryFolderId, folderPath, status: 'present' }).returning({ id: books.id });
|
||||
@@ -113,20 +113,14 @@ export class UploadProcessorService {
|
||||
|
||||
await tx.update(books).set({ primaryFileId: file.id }).where(eq(books.id, book.id));
|
||||
|
||||
return { bookId: book.id };
|
||||
return { bookId: book.id, created: true };
|
||||
});
|
||||
|
||||
const event = 'upload.schedule_metadata_fetch';
|
||||
const startedAt = Date.now();
|
||||
this.autoFetchOrchestrator?.scheduleIfEligible(bookId, libraryId, 'event_import').catch((err: Error) => {
|
||||
const errorClass = err.name ?? 'Error';
|
||||
const errorMessage = sanitizeLogValue(err.message);
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] libraryId=${libraryId} bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata fetch scheduling failed`,
|
||||
);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
return { bookId };
|
||||
processNewBookImportAsync(bookId: number, libraryId: number, absolutePath: string, format: string): void {
|
||||
void this.runNewBookImport(bookId, libraryId, absolutePath, format);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +155,36 @@ export class UploadProcessorService {
|
||||
}
|
||||
}
|
||||
|
||||
private async runNewBookImport(bookId: number, libraryId: number, absolutePath: string, format: string): Promise<void> {
|
||||
const event = 'upload.process_new_book_import';
|
||||
const startedAt = Date.now();
|
||||
this.logger.debug(`[${event}] [start] libraryId=${libraryId} bookId=${bookId} format=${format} - new book import processing started`);
|
||||
|
||||
if (METADATA_FORMATS.has(format)) {
|
||||
await this.runMetadataExtraction(bookId, absolutePath, format, 'upload.extract_metadata', startedAt);
|
||||
}
|
||||
|
||||
if (!this.autoFetchOrchestrator) {
|
||||
this.logger.debug(
|
||||
`[${event}] [end] libraryId=${libraryId} bookId=${bookId} durationMs=${Date.now() - startedAt} scheduled=false - new book import processing completed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const queued = await this.autoFetchOrchestrator.scheduleImportedBooksIfEligible(libraryId, [bookId]);
|
||||
this.logger.debug(
|
||||
`[${event}] [end] libraryId=${libraryId} bookId=${bookId} durationMs=${Date.now() - startedAt} scheduled=true queued=${queued} - new book import processing 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] libraryId=${libraryId} bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata fetch scheduling failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires-and-forgets per-file audio duration extraction for a file added to an existing book.
|
||||
* Unlike {@link extractMetadataAsync} this never overwrites shared book metadata (title, cover, etc.)
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('UploadService', () => {
|
||||
};
|
||||
const processor = {
|
||||
createBookRecord: vi.fn(),
|
||||
processNewBookImportAsync: vi.fn(),
|
||||
extractMetadataAsync: vi.fn(),
|
||||
extractAudioDurationAsync: vi.fn(),
|
||||
};
|
||||
@@ -100,7 +101,7 @@ describe('UploadService', () => {
|
||||
storage.streamToTemp.mockResolvedValue({ tempPath: '/tmp/upload.bin', sizeBytes: 456 });
|
||||
storage.moveToPath.mockResolvedValue(undefined);
|
||||
storage.cleanup.mockResolvedValue(undefined);
|
||||
processor.createBookRecord.mockResolvedValue({ bookId: 99 });
|
||||
processor.createBookRecord.mockResolvedValue({ bookId: 99, created: true });
|
||||
libraryService.verifyUserAccess.mockResolvedValue(undefined);
|
||||
appSettings.getUploadPattern.mockResolvedValue(null);
|
||||
appSettings.getUploadPatternBookPerFolder.mockResolvedValue(null);
|
||||
@@ -128,7 +129,7 @@ describe('UploadService', () => {
|
||||
mockStat.mockResolvedValue({ ino: 12345n, mtime: new Date('2025-01-01') } as any);
|
||||
});
|
||||
|
||||
it('uploads successfully and kicks off async metadata extraction', async () => {
|
||||
it('uploads successfully and processes a new-book import after the record is created', async () => {
|
||||
db.select
|
||||
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{authors:first}/{title}.{extension}' }]))
|
||||
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
|
||||
@@ -161,7 +162,19 @@ describe('UploadService', () => {
|
||||
'epub',
|
||||
456,
|
||||
);
|
||||
expect(processor.extractMetadataAsync).toHaveBeenCalledWith(99, '/library/Frank Herbert/Dune.epub', 'epub');
|
||||
expect(processor.processNewBookImportAsync).toHaveBeenCalledWith(99, 1, '/library/Frank Herbert/Dune.epub', 'epub');
|
||||
});
|
||||
|
||||
it('only extracts metadata when an upload adds a file to an existing book', async () => {
|
||||
processor.createBookRecord.mockResolvedValue({ bookId: 99, created: false });
|
||||
db.select
|
||||
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
|
||||
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
|
||||
|
||||
await service.upload(1, 2, 'raw.epub', {} as any, user);
|
||||
|
||||
expect(processor.extractMetadataAsync).toHaveBeenCalledWith(99, '/library/book/book.epub', 'epub');
|
||||
expect(processor.processNewBookImportAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sanitizes generated destination path tokens when cross-platform mode is enabled', async () => {
|
||||
|
||||
@@ -82,9 +82,21 @@ export class UploadService {
|
||||
shouldCleanupDestination = true;
|
||||
await this.storage.moveToPath(tempPath, absolutePath);
|
||||
|
||||
const { bookId } = await this.processor.createBookRecord(libraryId, folder.id, bookFolderPath, absolutePath, relPath, format, sizeBytes);
|
||||
const { bookId, created } = await this.processor.createBookRecord(
|
||||
libraryId,
|
||||
folder.id,
|
||||
bookFolderPath,
|
||||
absolutePath,
|
||||
relPath,
|
||||
format,
|
||||
sizeBytes,
|
||||
);
|
||||
|
||||
this.processor.extractMetadataAsync(bookId, absolutePath, format);
|
||||
if (created) {
|
||||
this.processor.processNewBookImportAsync(bookId, libraryId, absolutePath, format);
|
||||
} else {
|
||||
this.processor.extractMetadataAsync(bookId, absolutePath, format);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} userId=${user.id} folderId=${folder.id} bookId=${bookId} format=${format} sizeBytes=${sizeBytes} durationMs=${Date.now() - startedAt} - upload completed`,
|
||||
|
||||
Reference in New Issue
Block a user