refactor(metadata): await metadata score calculations on metadata changes

Ensure metadata score calculations are awaited when book metadata or covers are saved or updated, and add concurrency queuing for on-demand score recalculations.
This commit is contained in:
neon
2026-07-27 06:13:54 +00:00
parent 20561c28df
commit 5290e63375
22 changed files with 217 additions and 56 deletions
@@ -8,6 +8,7 @@ import { AuthModule } from '../auth/auth.module';
import { BookModule } from '../book/book.module'; import { BookModule } from '../book/book.module';
import { LibraryModule } from '../library/library.module'; import { LibraryModule } from '../library/library.module';
import { MetadataModule } from '../metadata/metadata.module'; import { MetadataModule } from '../metadata/metadata.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { NotificationModule } from '../notification/notification.module'; import { NotificationModule } from '../notification/notification.module';
import { AuthorImageStorageService } from './author-image-storage.service'; import { AuthorImageStorageService } from './author-image-storage.service';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service'; import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
@@ -33,6 +34,7 @@ const AUTHOR_PROVIDER_CLASSES = [AudnexusAuthorMetadataProvider];
LibraryModule, LibraryModule,
AppSettingsModule, AppSettingsModule,
MetadataModule, MetadataModule,
MetadataScoreModule,
forwardRef(() => NotificationModule), forwardRef(() => NotificationModule),
AuthModule, AuthModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
@@ -133,6 +133,7 @@ describe('AuthorsRepository', () => {
await expect(repo.findVisibleAuthorIds([], [1])).resolves.toEqual([]); await expect(repo.findVisibleAuthorIds([], [1])).resolves.toEqual([]);
await expect(repo.findVisibleAuthorIds([1], [])).resolves.toEqual([]); await expect(repo.findVisibleAuthorIds([1], [])).resolves.toEqual([]);
await expect(repo.countDistinctBooks([])).resolves.toBe(0); await expect(repo.countDistinctBooks([])).resolves.toBe(0);
await expect(repo.findBookIdsByAuthorIds([])).resolves.toEqual([]);
await expect(repo.findRelatedLibraryIds([])).resolves.toEqual([]); await expect(repo.findRelatedLibraryIds([])).resolves.toEqual([]);
await expect(repo.mergeAuthors(1, [])).resolves.toBeUndefined(); await expect(repo.mergeAuthors(1, [])).resolves.toBeUndefined();
await expect(repo.deleteAuthors([])).resolves.toBeUndefined(); await expect(repo.deleteAuthors([])).resolves.toBeUndefined();
@@ -144,10 +145,14 @@ describe('AuthorsRepository', () => {
it('maps distinct ids for visibility and related-library lookup queries', async () => { it('maps distinct ids for visibility and related-library lookup queries', async () => {
const { db, selectDistinctBuilder } = makeDb(); const { db, selectDistinctBuilder } = makeDb();
selectDistinctBuilder.where.mockResolvedValueOnce([{ id: 5 }, { id: 8 }]).mockResolvedValueOnce([{ libraryId: 11 }, { libraryId: 12 }]); selectDistinctBuilder.where
.mockResolvedValueOnce([{ id: 5 }, { id: 8 }])
.mockResolvedValueOnce([{ bookId: 21 }, { bookId: 34 }])
.mockResolvedValueOnce([{ libraryId: 11 }, { libraryId: 12 }]);
const repo = new AuthorsRepository(db as never); const repo = new AuthorsRepository(db as never);
await expect(repo.findVisibleAuthorIds([5, 8], [100])).resolves.toEqual([5, 8]); await expect(repo.findVisibleAuthorIds([5, 8], [100])).resolves.toEqual([5, 8]);
await expect(repo.findBookIdsByAuthorIds([1, 2])).resolves.toEqual([21, 34]);
await expect(repo.findRelatedLibraryIds([1, 2])).resolves.toEqual([11, 12]); await expect(repo.findRelatedLibraryIds([1, 2])).resolves.toEqual([11, 12]);
}); });
@@ -249,6 +249,12 @@ export class AuthorsRepository {
return Number(total); return Number(total);
} }
async findBookIdsByAuthorIds(authorIds: number[]): Promise<number[]> {
if (authorIds.length === 0) return [];
const rows = await this.db.selectDistinct({ bookId: bookAuthors.bookId }).from(bookAuthors).where(inArray(bookAuthors.authorId, authorIds));
return rows.map((row) => row.bookId);
}
async findRelatedLibraryIds(authorIds: number[]): Promise<number[]> { async findRelatedLibraryIds(authorIds: number[]): Promise<number[]> {
if (authorIds.length === 0) return []; if (authorIds.length === 0) return [];
const rows = await this.db const rows = await this.db
@@ -22,6 +22,7 @@ describe('AuthorsService', () => {
updateAuthorById: vi.fn(), updateAuthorById: vi.fn(),
findVisibleAuthorIds: vi.fn(), findVisibleAuthorIds: vi.fn(),
countDistinctBooks: vi.fn(), countDistinctBooks: vi.fn(),
findBookIdsByAuthorIds: vi.fn(),
mergeAuthors: vi.fn(), mergeAuthors: vi.fn(),
deleteAuthors: vi.fn(), deleteAuthors: vi.fn(),
findRelatedLibraryIds: vi.fn(), findRelatedLibraryIds: vi.fn(),
@@ -67,6 +68,9 @@ describe('AuthorsService', () => {
const appSettings = { const appSettings = {
getAuthorsAutoEnrichmentWriteMode: vi.fn(), getAuthorsAutoEnrichmentWriteMode: vi.fn(),
}; };
const metadataScoreService = {
calculateAndSaveMany: vi.fn(),
};
let service: AuthorsService; let service: AuthorsService;
@@ -81,6 +85,7 @@ describe('AuthorsService', () => {
authorImageStorage as any, authorImageStorage as any,
enrichmentExecutor as any, enrichmentExecutor as any,
enrichmentOrchestrator as any, enrichmentOrchestrator as any,
metadataScoreService as any,
); );
libraryService.findAll.mockResolvedValue([{ id: 1 }, { id: 2 }]); libraryService.findAll.mockResolvedValue([{ id: 1 }, { id: 2 }]);
authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue(null); authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue(null);
@@ -88,6 +93,7 @@ describe('AuthorsService', () => {
enrichmentOrchestrator.schedule.mockResolvedValue(1); enrichmentOrchestrator.schedule.mockResolvedValue(1);
enrichmentOrchestrator.backfillLinkedAuthors.mockResolvedValue(8); enrichmentOrchestrator.backfillLinkedAuthors.mockResolvedValue(8);
appSettings.getAuthorsAutoEnrichmentWriteMode.mockResolvedValue('missing_only'); appSettings.getAuthorsAutoEnrichmentWriteMode.mockResolvedValue('missing_only');
metadataScoreService.calculateAndSaveMany.mockResolvedValue(undefined);
vi.mocked(assembleBookCards).mockReturnValue([]); vi.mocked(assembleBookCards).mockReturnValue([]);
}); });
@@ -114,12 +120,13 @@ describe('AuthorsService', () => {
it('delete removes authors and returns impacted count', async () => { it('delete removes authors and returns impacted count', async () => {
authorsRepo.findVisibleAuthorIds.mockResolvedValue([10, 11]); authorsRepo.findVisibleAuthorIds.mockResolvedValue([10, 11]);
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1, 2]); authorsRepo.findRelatedLibraryIds.mockResolvedValue([1, 2]);
authorsRepo.countDistinctBooks.mockResolvedValue(6); authorsRepo.findBookIdsByAuthorIds.mockResolvedValue([1, 2, 3, 4, 5, 6]);
authorsRepo.deleteAuthors.mockResolvedValue(undefined); authorsRepo.deleteAuthors.mockResolvedValue(undefined);
const result = await service.delete(reqUser(7, true), { authorIds: [10, 11, 11] }); const result = await service.delete(reqUser(7, true), { authorIds: [10, 11, 11] });
expect(authorsRepo.deleteAuthors).toHaveBeenCalledWith([10, 11]); expect(authorsRepo.deleteAuthors).toHaveBeenCalledWith([10, 11]);
expect(metadataScoreService.calculateAndSaveMany).toHaveBeenCalledWith([1, 2, 3, 4, 5, 6]);
expect(result.deletedAuthorIds).toEqual([10, 11]); expect(result.deletedAuthorIds).toEqual([10, 11]);
expect(result.affectedBookCount).toBe(6); expect(result.affectedBookCount).toBe(6);
}); });
@@ -127,7 +134,7 @@ describe('AuthorsService', () => {
it('merge deduplicates sources, merges, and returns impacted count', async () => { it('merge deduplicates sources, merges, and returns impacted count', async () => {
authorsRepo.findVisibleAuthorIds.mockResolvedValue([10, 11, 12]); authorsRepo.findVisibleAuthorIds.mockResolvedValue([10, 11, 12]);
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1, 2]); authorsRepo.findRelatedLibraryIds.mockResolvedValue([1, 2]);
authorsRepo.countDistinctBooks.mockResolvedValue(8); authorsRepo.findBookIdsByAuthorIds.mockResolvedValue([1, 2, 3, 4, 5, 6, 7, 8]);
authorsRepo.mergeAuthors.mockResolvedValue(undefined); authorsRepo.mergeAuthors.mockResolvedValue(undefined);
authorsRepo.findById.mockResolvedValue({ authorsRepo.findById.mockResolvedValue({
id: 10, id: 10,
@@ -141,6 +148,7 @@ describe('AuthorsService', () => {
const result = await service.merge(reqUser(7, true), { targetAuthorId: 10, sourceAuthorIds: [10, 11, 11, 12] }); const result = await service.merge(reqUser(7, true), { targetAuthorId: 10, sourceAuthorIds: [10, 11, 11, 12] });
expect(authorsRepo.mergeAuthors).toHaveBeenCalledWith(10, [11, 12]); expect(authorsRepo.mergeAuthors).toHaveBeenCalledWith(10, [11, 12]);
expect(metadataScoreService.calculateAndSaveMany).toHaveBeenCalledWith([1, 2, 3, 4, 5, 6, 7, 8]);
expect(enrichmentOrchestrator.schedule).toHaveBeenCalledWith(10, AUTHOR_ENRICHMENT_REASONS.AUTHOR_MERGE_TARGET); expect(enrichmentOrchestrator.schedule).toHaveBeenCalledWith(10, AUTHOR_ENRICHMENT_REASONS.AUTHOR_MERGE_TARGET);
expect(result.mergedAuthorIds).toEqual([11, 12]); expect(result.mergedAuthorIds).toEqual([11, 12]);
expect(result.affectedBookCount).toBe(8); expect(result.affectedBookCount).toBe(8);
+10 -6
View File
@@ -20,6 +20,7 @@ import { books } from '../../db/schema';
import { BookReadService } from '../book/book-read.service'; import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service'; import { LibraryService } from '../library/library.service';
import { AppSettingsService } from '../app-settings/app-settings.service'; import { AppSettingsService } from '../app-settings/app-settings.service';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { AuthorImageStorageError, AuthorImageStorageService } from './author-image-storage.service'; import { AuthorImageStorageError, AuthorImageStorageService } from './author-image-storage.service';
import { AUTHOR_ENRICHMENT_REASONS } from './author-enrichment-reasons'; import { AUTHOR_ENRICHMENT_REASONS } from './author-enrichment-reasons';
import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service'; import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service';
@@ -50,6 +51,7 @@ export class AuthorsService {
private readonly authorImageStorage: AuthorImageStorageService, private readonly authorImageStorage: AuthorImageStorageService,
private readonly enrichmentExecutor: AuthorEnrichmentExecutorService, private readonly enrichmentExecutor: AuthorEnrichmentExecutorService,
private readonly enrichmentOrchestrator: AuthorEnrichmentOrchestratorService, private readonly enrichmentOrchestrator: AuthorEnrichmentOrchestratorService,
private readonly metadataScoreService: MetadataScoreService,
) {} ) {}
private assertPaginationWindow(page: number, size: number): void { private assertPaginationWindow(page: number, size: number): void {
@@ -268,18 +270,19 @@ export class AuthorsService {
const allAuthorIds = [dto.targetAuthorId, ...uniqueSourceIds]; const allAuthorIds = [dto.targetAuthorId, ...uniqueSourceIds];
await this.assertMutationAccess(user, allAuthorIds); await this.assertMutationAccess(user, allAuthorIds);
const affectedBookCount = await this.authorsRepo.countDistinctBooks(uniqueSourceIds); const affectedBookIds = await this.authorsRepo.findBookIdsByAuthorIds(uniqueSourceIds);
await this.authorsRepo.mergeAuthors(dto.targetAuthorId, uniqueSourceIds); await this.authorsRepo.mergeAuthors(dto.targetAuthorId, uniqueSourceIds);
await this.metadataScoreService.calculateAndSaveMany(affectedBookIds);
await this.enrichmentOrchestrator.schedule(dto.targetAuthorId, AUTHOR_ENRICHMENT_REASONS.AUTHOR_MERGE_TARGET); await this.enrichmentOrchestrator.schedule(dto.targetAuthorId, AUTHOR_ENRICHMENT_REASONS.AUTHOR_MERGE_TARGET);
const target = await this.findOne(user, dto.targetAuthorId); const target = await this.findOne(user, dto.targetAuthorId);
this.logger.log( this.logger.log(
`[${event}] [end] userId=${user.id} targetAuthorId=${dto.targetAuthorId} durationMs=${Date.now() - startedAt} mergedCount=${uniqueSourceIds.length} affectedBookCount=${affectedBookCount} - author merge completed`, `[${event}] [end] userId=${user.id} targetAuthorId=${dto.targetAuthorId} durationMs=${Date.now() - startedAt} mergedCount=${uniqueSourceIds.length} affectedBookCount=${affectedBookIds.length} - author merge completed`,
); );
return { return {
target, target,
mergedAuthorIds: uniqueSourceIds, mergedAuthorIds: uniqueSourceIds,
affectedBookCount, affectedBookCount: affectedBookIds.length,
}; };
} catch (err) { } catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error'; const errorClass = err instanceof Error ? err.name : 'Error';
@@ -303,15 +306,16 @@ export class AuthorsService {
const authorIds = [...new Set(dto.authorIds)]; const authorIds = [...new Set(dto.authorIds)];
await this.assertMutationAccess(user, authorIds); await this.assertMutationAccess(user, authorIds);
const affectedBookCount = await this.authorsRepo.countDistinctBooks(authorIds); const affectedBookIds = await this.authorsRepo.findBookIdsByAuthorIds(authorIds);
await this.authorsRepo.deleteAuthors(authorIds); await this.authorsRepo.deleteAuthors(authorIds);
await this.metadataScoreService.calculateAndSaveMany(affectedBookIds);
this.logger.log( this.logger.log(
`[${event}] [end] userId=${user.id} durationMs=${Date.now() - startedAt} deletedCount=${authorIds.length} affectedBookCount=${affectedBookCount} - author delete completed`, `[${event}] [end] userId=${user.id} durationMs=${Date.now() - startedAt} deletedCount=${authorIds.length} affectedBookCount=${affectedBookIds.length} - author delete completed`,
); );
return { return {
deletedAuthorIds: authorIds, deletedAuthorIds: authorIds,
affectedBookCount, affectedBookCount: affectedBookIds.length,
}; };
} catch (err) { } catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error'; const errorClass = err instanceof Error ? err.name : 'Error';
@@ -48,6 +48,9 @@ function makeService() {
replaceNarrators: vi.fn().mockResolvedValue(undefined), replaceNarrators: vi.fn().mockResolvedValue(undefined),
upsertComicMetadata: vi.fn().mockResolvedValue(undefined), upsertComicMetadata: vi.fn().mockResolvedValue(undefined),
}; };
const metadataScoreService = {
calculateAndSave: vi.fn().mockResolvedValue(undefined),
};
const bookReadService = { const bookReadService = {
replaceCommunityRatings: vi.fn().mockResolvedValue(undefined), replaceCommunityRatings: vi.fn().mockResolvedValue(undefined),
}; };
@@ -82,6 +85,7 @@ function makeService() {
libraryService as never, libraryService as never,
appSettings as never, appSettings as never,
metadataService as never, metadataService as never,
metadataScoreService as never,
bookReadService as never, bookReadService as never,
validator as never, validator as never,
storage as never, storage as never,
@@ -101,6 +105,7 @@ function makeService() {
libraryService, libraryService,
appSettings, appSettings,
metadataService, metadataService,
metadataScoreService,
bookReadService, bookReadService,
validator, validator,
storage, storage,
@@ -1066,7 +1071,7 @@ describe('BookDockFinalizeService', () => {
}); });
it('applyMetadata updates scalar metadata fields and related author/genre rows', async () => { it('applyMetadata updates scalar metadata fields and related author/genre rows', async () => {
const { service, db, metadataService } = makeService(); const { service, db, metadataService, metadataScoreService } = makeService();
const updateChain = { const updateChain = {
set: vi.fn(), set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined), where: vi.fn().mockResolvedValue(undefined),
@@ -1109,6 +1114,20 @@ describe('BookDockFinalizeService', () => {
); );
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(15, [{ name: 'Author A', sortName: null }]); expect(metadataService.replaceAuthors).toHaveBeenCalledWith(15, [{ name: 'Author A', sortName: null }]);
expect(metadataService.replaceGenres).toHaveBeenCalledWith(15, ['Fantasy']); expect(metadataService.replaceGenres).toHaveBeenCalledWith(15, ['Fantasy']);
expect(metadataScoreService.calculateAndSave).toHaveBeenCalledWith(15);
});
it('applyMetadata propagates score persistence failures', async () => {
const { service, db, metadataScoreService } = makeService();
const updateChain = {
set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined),
};
updateChain.set.mockReturnValue(updateChain);
db.update.mockReturnValue(updateChain);
metadataScoreService.calculateAndSave.mockRejectedValue(new Error('score failed'));
await expect((service as any).applyMetadata(15, makeRow())).rejects.toThrow('score failed');
}); });
it('applyMetadata nulls publishedYear when it is outside database bounds', async () => { it('applyMetadata nulls publishedYear when it is outside database bounds', async () => {
@@ -42,6 +42,7 @@ import { bookMetadata, libraries, libraryFolders } from '../../db/schema';
import { AppSettingsService } from '../app-settings/app-settings.service'; import { AppSettingsService } from '../app-settings/app-settings.service';
import { LibraryService } from '../library/library.service'; import { LibraryService } from '../library/library.service';
import { MetadataService } from '../metadata/metadata.service'; import { MetadataService } from '../metadata/metadata.service';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { UploadProcessorService } from '../upload/upload-processor.service'; import { UploadProcessorService } from '../upload/upload-processor.service';
import { UploadStorageService } from '../upload/upload-storage.service'; import { UploadStorageService } from '../upload/upload-storage.service';
import { UploadValidatorService } from '../upload/upload-validator.service'; import { UploadValidatorService } from '../upload/upload-validator.service';
@@ -133,6 +134,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
private readonly libraryService: LibraryService, private readonly libraryService: LibraryService,
private readonly appSettings: AppSettingsService, private readonly appSettings: AppSettingsService,
private readonly metadataService: MetadataService, private readonly metadataService: MetadataService,
private readonly metadataScoreService: MetadataScoreService,
private readonly bookReadService: BookReadService, private readonly bookReadService: BookReadService,
private readonly validator: UploadValidatorService, private readonly validator: UploadValidatorService,
private readonly storage: UploadStorageService, private readonly storage: UploadStorageService,
@@ -855,6 +857,8 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
audio.narrators.map((name) => ({ name, sortName: null })), audio.narrators.map((name) => ({ name, sortName: null })),
); );
} }
await this.metadataScoreService.calculateAndSave(bookId);
} }
private async cleanupBookDockRecord(row: BookDockFileRow): Promise<void> { private async cleanupBookDockRecord(row: BookDockFileRow): Promise<void> {
@@ -9,6 +9,7 @@ import { BookModule } from '../book/book.module';
import { LibraryModule } from '../library/library.module'; import { LibraryModule } from '../library/library.module';
import { MetadataFetchModule } from '../metadata-fetch/metadata-fetch.module'; import { MetadataFetchModule } from '../metadata-fetch/metadata-fetch.module';
import { MetadataModule } from '../metadata/metadata.module'; import { MetadataModule } from '../metadata/metadata.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { NotificationModule } from '../notification/notification.module'; import { NotificationModule } from '../notification/notification.module';
import { UploadModule } from '../upload/upload.module'; import { UploadModule } from '../upload/upload.module';
import { BookDockController } from './book-dock.controller'; import { BookDockController } from './book-dock.controller';
@@ -30,6 +31,7 @@ import { BookDockRepository } from './book-dock.repository';
LibraryModule, LibraryModule,
MetadataFetchModule, MetadataFetchModule,
MetadataModule, MetadataModule,
MetadataScoreModule,
forwardRef(() => NotificationModule), forwardRef(() => NotificationModule),
AppSettingsModule, AppSettingsModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
@@ -469,7 +469,7 @@ describe('BookMetadataFetchOrchestratorService', () => {
); );
}); });
it('processOne marks done and clears session when metadata pipeline succeeds', async () => { it('processOne marks the queue item failed when score persistence fails', async () => {
const { service, queueRepo, bookReadService, pipeline, scoreService, session } = makeService(); const { service, queueRepo, bookReadService, pipeline, scoreService, session } = makeService();
session.addToTotal(1); session.addToTotal(1);
bookReadService.findById.mockResolvedValue({ bookReadService.findById.mockResolvedValue({
@@ -488,9 +488,9 @@ describe('BookMetadataFetchOrchestratorService', () => {
await (service as any).processOne(90, 'Book'); await (service as any).processOne(90, 'Book');
expect(queueRepo.markDone).toHaveBeenCalledWith(90); expect(queueRepo.markDone).not.toHaveBeenCalled();
expect(queueRepo.markFailed).not.toHaveBeenCalled(); expect(queueRepo.markFailed).toHaveBeenCalledWith(90, 'score delayed', undefined);
expect(session.getSnapshot().sessionDone).toBe(1); expect(session.getSnapshot().sessionDone).toBe(0);
expect(session.getSnapshot().currentItemName).toBeNull(); expect(session.getSnapshot().currentItemName).toBeNull();
}); });
@@ -257,13 +257,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
await this.persistResolved(bookId, resolved, providerIds, authorRows, genreRows, narratorRows); await this.persistResolved(bookId, resolved, providerIds, authorRows, genreRows, narratorRows);
this.scoreService await this.scoreService.calculateAndSave(bookId);
.calculateAndSave(bookId)
.catch((err: Error) =>
this.logger.warn(
`[book.metadata_fetch.score_recalc] [fail] bookId=${bookId} errorClass=${err.name} error="${sanitizeLogValue(err.message)}" - metadata score recalculation failed`,
),
);
this.logger.debug(`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} - metadata fetch completed`); this.logger.debug(`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} - metadata fetch completed`);
await this.queueRepo.markDone(bookId); await this.queueRepo.markDone(bookId);
+6 -5
View File
@@ -178,7 +178,11 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
}; };
const scoreService = { const scoreService = {
calculateAndSave: vi.fn().mockResolvedValue(undefined), calculateAndSave: vi.fn().mockResolvedValue(undefined),
calculateAndSaveMany: vi.fn(),
}; };
scoreService.calculateAndSaveMany.mockImplementation((bookIds: number[]) =>
Promise.all(bookIds.map((bookId) => scoreService.calculateAndSave(bookId))).then(() => undefined),
);
const embedder = { const embedder = {
embedBook: vi.fn().mockResolvedValue(undefined), embedBook: vi.fn().mockResolvedValue(undefined),
}; };
@@ -3344,18 +3348,15 @@ describe('BookService', () => {
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(3); expect(scoreService.calculateAndSave).toHaveBeenCalledWith(3);
}); });
it('bulkSetRating logs score recalculation failures without interrupting the update', async () => { it('bulkSetRating propagates score recalculation failures', async () => {
const { service, bookRepo, scoreService } = makeService(); const { service, bookRepo, scoreService } = makeService();
const user = makeUser({ id: 42 }); const user = makeUser({ id: 42 });
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined); vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
scoreService.calculateAndSave.mockRejectedValue(new Error('score exploded')); scoreService.calculateAndSave.mockRejectedValue(new Error('score exploded'));
await service.bulkSetRating([3], 4, user); await expect(service.bulkSetRating([3], 4, user)).rejects.toThrow('score exploded');
await Promise.resolve();
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3], 4, 42); expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3], 4, 42);
expect(warnSpy).toHaveBeenCalledWith('Score calculation failed for book 3: score exploded');
}); });
it('bulkSetMetadata updates a single metadata field and queues follow-up work', async () => { it('bulkSetMetadata updates a single metadata field and queues follow-up work', async () => {
+8 -9
View File
@@ -1772,7 +1772,7 @@ export class BookService {
this.fileRenameService?.scheduleRename(id, user.id); this.fileRenameService?.scheduleRename(id, user.id);
} }
} }
this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`)); await this.scoreService.calculateAndSave(id);
} }
const detail = await this.getDetail(id, user); const detail = await this.getDetail(id, user);
@@ -2206,7 +2206,7 @@ export class BookService {
const updatableIds = bookIds.filter((bookId) => !lockedIds.has(bookId)); const updatableIds = bookIds.filter((bookId) => !lockedIds.has(bookId));
if (updatableIds.length > 0) { if (updatableIds.length > 0) {
await this.bookRepo.bulkSetRating(updatableIds, rating, user.id); await this.bookRepo.bulkSetRating(updatableIds, rating, user.id);
this.triggerPostMetadataUpdateEffects(updatableIds, user.id); await this.triggerPostMetadataUpdateEffects(updatableIds, user.id);
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, { this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id, userId: user.id,
bookIds: updatableIds, bookIds: updatableIds,
@@ -2273,7 +2273,7 @@ export class BookService {
} }
}); });
} }
this.triggerPostMetadataUpdateEffects(updatableIds, user.id); await this.triggerPostMetadataUpdateEffects(updatableIds, user.id);
} }
this.logger.log( 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`, `[${event}] [end] userId=${user.id} count=${bookIds.length} field=${field} updated=${updatableIds.length} skippedLocked=${lockedIds.size} durationMs=${Date.now() - startedAt} - bulk set metadata completed`,
@@ -2300,7 +2300,7 @@ export class BookService {
await this.metadataService.replaceTags(bookId, next, { executor: tx }); await this.metadataService.replaceTags(bookId, next, { executor: tx });
} }
}); });
this.triggerPostMetadataUpdateEffects(bookIds, user.id); await this.triggerPostMetadataUpdateEffects(bookIds, user.id);
this.logger.log( this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} mode=${mode} tagCount=${tags.length} durationMs=${Date.now() - startedAt} - bulk update tags completed`, `[${event}] [end] userId=${user.id} count=${bookIds.length} mode=${mode} tagCount=${tags.length} durationMs=${Date.now() - startedAt} - bulk update tags completed`,
@@ -2495,7 +2495,7 @@ export class BookService {
} }
if (allUpdatedBookIds.size > 0) { if (allUpdatedBookIds.size > 0) {
this.triggerPostMetadataUpdateEffects([...allUpdatedBookIds], user.id); await this.triggerPostMetadataUpdateEffects([...allUpdatedBookIds], user.id);
} }
const result: BulkEditMetadataResult = { const result: BulkEditMetadataResult = {
@@ -3285,14 +3285,13 @@ export class BookService {
); );
} }
private triggerPostMetadataUpdateEffects(bookIds: number[], userId: number): void { private async triggerPostMetadataUpdateEffects(bookIds: number[], userId: number): Promise<void> {
for (const bookId of bookIds) { for (const bookId of bookIds) {
this.fileWriteService?.scheduleWrite(bookId, 'auto', userId); this.fileWriteService?.scheduleWrite(bookId, 'auto', userId);
this.fileRenameService?.scheduleRename(bookId, userId); this.fileRenameService?.scheduleRename(bookId, userId);
void this.scoreService
.calculateAndSave(bookId)
.catch((err: Error) => this.logger.warn(`Score calculation failed for book ${bookId}: ${err.message}`));
} }
await this.scoreService.calculateAndSaveMany(bookIds);
} }
private resolveChapters( private resolveChapters(
+2 -1
View File
@@ -4,6 +4,7 @@ import { BookMetadataLockModule } from '../book-metadata-lock/book-metadata-lock
import { FileWriteModule } from '../file-write/file-write.module'; import { FileWriteModule } from '../file-write/file-write.module';
import { LibraryModule } from '../library/library.module'; import { LibraryModule } from '../library/library.module';
import { MetadataPreferencesModule } from '../metadata-preferences/metadata-preferences.module'; import { MetadataPreferencesModule } from '../metadata-preferences/metadata-preferences.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { COVER_PROVIDERS } from './constants'; import { COVER_PROVIDERS } from './constants';
import { CoverController } from './cover.controller'; import { CoverController } from './cover.controller';
import { CoverService } from './cover.service'; import { CoverService } from './cover.service';
@@ -16,7 +17,7 @@ import { CoverProviderRegistry } from './provider-registry';
const PROVIDER_CLASSES = [DuckDuckGoCoverProvider, ITunesCoverProvider, AudiobookCoversCoverProvider]; const PROVIDER_CLASSES = [DuckDuckGoCoverProvider, ITunesCoverProvider, AudiobookCoversCoverProvider];
@Module({ @Module({
imports: [BookModule, BookMetadataLockModule, FileWriteModule, LibraryModule, MetadataPreferencesModule], imports: [BookModule, BookMetadataLockModule, FileWriteModule, LibraryModule, MetadataPreferencesModule, MetadataScoreModule],
controllers: [CoverController], controllers: [CoverController],
providers: [ providers: [
...PROVIDER_CLASSES, ...PROVIDER_CLASSES,
+22 -2
View File
@@ -105,6 +105,7 @@ function createService(providerRegistry: CoverProviderRegistry, options?: { asse
{ verifyUserAccess: vi.fn().mockResolvedValue(undefined) } as never, { verifyUserAccess: vi.fn().mockResolvedValue(undefined) } as never,
{ get: vi.fn().mockReturnValue('/tmp/books') } as never, { get: vi.fn().mockReturnValue('/tmp/books') } as never,
providerRegistry, providerRegistry,
{ calculateAndSave: vi.fn().mockResolvedValue(undefined) } as never,
); );
} }
@@ -116,6 +117,9 @@ function createMutationService(options?: { assertFieldsUnlocked?: ReturnType<typ
const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const update = vi.fn().mockReturnValue({ set: updateSet }); const update = vi.fn().mockReturnValue({ set: updateSet });
const mockDb = { insert, update }; const mockDb = { insert, update };
const scoreService = {
calculateAndSave: vi.fn().mockResolvedValue(undefined),
};
const service = new CoverService( const service = new CoverService(
mockDb as never, mockDb as never,
@@ -125,9 +129,10 @@ function createMutationService(options?: { assertFieldsUnlocked?: ReturnType<typ
{ verifyUserAccess: vi.fn().mockResolvedValue(undefined) } as never, { verifyUserAccess: vi.fn().mockResolvedValue(undefined) } as never,
{ get: vi.fn().mockReturnValue('/tmp/books') } as never, { get: vi.fn().mockReturnValue('/tmp/books') } as never,
{ select: vi.fn().mockReturnValue([]) } as unknown as CoverProviderRegistry, { select: vi.fn().mockReturnValue([]) } as unknown as CoverProviderRegistry,
scoreService as never,
); );
return { service, mockDb, updateSet }; return { service, mockDb, updateSet, scoreService };
} }
describe('CoverService', () => { describe('CoverService', () => {
@@ -474,7 +479,7 @@ describe('CoverService', () => {
}); });
it('saves custom cover file and thumbnail when unlocked', async () => { it('saves custom cover file and thumbnail when unlocked', async () => {
const { service, mockDb, updateSet } = createMutationService(); const { service, mockDb, updateSet, scoreService } = createMutationService();
const source = Buffer.from('image-data'); const source = Buffer.from('image-data');
const normalized = Buffer.from('normalized-image'); const normalized = Buffer.from('normalized-image');
@@ -510,6 +515,21 @@ describe('CoverService', () => {
); );
expect(mockDb.update).toHaveBeenCalledWith(books); expect(mockDb.update).toHaveBeenCalledWith(books);
expect(updateSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }); expect(updateSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) });
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(12);
});
it('propagates score persistence failures after updating the cover source', async () => {
const { service, scoreService } = createMutationService();
scoreService.calculateAndSave.mockRejectedValue(new Error('score failed'));
vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12');
vi.mocked(imageExt).mockReturnValue('jpg');
vi.mocked(generateThumbnail).mockResolvedValue(Buffer.from('thumb'));
vi.mocked(readdir).mockResolvedValue([] as never);
vi.mocked(mkdir).mockResolvedValue(undefined as never);
vi.mocked(writeFile).mockResolvedValue(undefined);
vi.mocked(rename).mockResolvedValue(undefined);
await expect(service.uploadCover(12, Buffer.from('image-data'), 'image/jpeg', makeUser())).rejects.toThrow('score failed');
}); });
it('preserves the existing cover when image normalization fails', async () => { it('preserves the existing cover when image normalization fails', async () => {
@@ -19,6 +19,7 @@ import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-loc
import { FileWriteService } from '../file-write/file-write.service'; import { FileWriteService } from '../file-write/file-write.service';
import { LibraryService } from '../library/library.service'; import { LibraryService } from '../library/library.service';
import { generateThumbnail, imageExt, normalizeProgressiveJpeg } from '../metadata/lib/cover'; import { generateThumbnail, imageExt, normalizeProgressiveJpeg } from '../metadata/lib/cover';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { import {
COVER_CUSTOM_FILE_PREFIX, COVER_CUSTOM_FILE_PREFIX,
COVER_PROXY_MAX_IMAGE_BYTES, COVER_PROXY_MAX_IMAGE_BYTES,
@@ -54,6 +55,7 @@ export class CoverService {
private readonly libraryService: LibraryService, private readonly libraryService: LibraryService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly providerRegistry: CoverProviderRegistry, private readonly providerRegistry: CoverProviderRegistry,
private readonly metadataScoreService: MetadataScoreService,
) { ) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!; this.appDataPath = this.config.get<string>('storage.appDataPath')!;
} }
@@ -299,6 +301,7 @@ export class CoverService {
.values({ bookId, coverSource: source, updatedAt: now }) .values({ bookId, coverSource: source, updatedAt: now })
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { coverSource: source, updatedAt: now } }); .onConflictDoUpdate({ target: bookMetadata.bookId, set: { coverSource: source, updatedAt: now } });
await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId)); await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId));
await this.metadataScoreService.calculateAndSave(bookId);
} }
private async fetchRemoteImage(rawUrl: string): Promise<{ buffer: Buffer; contentType: string }> { private async fetchRemoteImage(rawUrl: string): Promise<{ buffer: Buffer; contentType: string }> {
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { AuthorsModule } from '../authors/authors.module'; import { AuthorsModule } from '../authors/authors.module';
import { FileWriteModule } from '../file-write/file-write.module'; import { FileWriteModule } from '../file-write/file-write.module';
import { LibraryModule } from '../library/library.module'; import { LibraryModule } from '../library/library.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { EntityManagerController } from './entity-manager.controller'; import { EntityManagerController } from './entity-manager.controller';
import { EntityManagerRepository } from './entity-manager.repository'; import { EntityManagerRepository } from './entity-manager.repository';
import { EntityManagerService } from './entity-manager.service'; import { EntityManagerService } from './entity-manager.service';
@@ -16,7 +17,7 @@ import { SeriesStrategy } from './strategies/series.strategy';
import { TagStrategy } from './strategies/tag.strategy'; import { TagStrategy } from './strategies/tag.strategy';
@Module({ @Module({
imports: [AuthorsModule, FileWriteModule, LibraryModule], imports: [AuthorsModule, FileWriteModule, LibraryModule, MetadataScoreModule],
controllers: [EntityManagerController], controllers: [EntityManagerController],
providers: [ providers: [
EntityManagerService, EntityManagerService,
@@ -43,6 +43,9 @@ function makeService() {
const fileWriteService = { const fileWriteService = {
scheduleWrite: vi.fn(), scheduleWrite: vi.fn(),
}; };
const metadataScoreService = {
calculateAndSaveMany: vi.fn().mockResolvedValue(undefined),
};
const duplicateCompute = { const duplicateCompute = {
readCandidatePairs: vi.fn().mockResolvedValue([]), readCandidatePairs: vi.fn().mockResolvedValue([]),
@@ -63,6 +66,7 @@ function makeService() {
repo as any, repo as any,
libraryService as any, libraryService as any,
fileWriteService as any, fileWriteService as any,
metadataScoreService as any,
duplicateCompute as any, duplicateCompute as any,
authorStrategy as any, authorStrategy as any,
genreStrategy as any, genreStrategy as any,
@@ -78,6 +82,7 @@ function makeService() {
repo, repo,
libraryService, libraryService,
fileWriteService, fileWriteService,
metadataScoreService,
duplicateCompute, duplicateCompute,
strategies: { strategies: {
author: authorStrategy, author: authorStrategy,
@@ -600,7 +605,7 @@ describe('EntityManagerService', () => {
describe('merge', () => { describe('merge', () => {
it('calls strategy merge and cleans up dismissed pairs for first-class', async () => { it('calls strategy merge and cleans up dismissed pairs for first-class', async () => {
const { service, strategies, repo, duplicateCompute } = makeService(); const { service, strategies, repo, duplicateCompute, metadataScoreService } = makeService();
(strategies.author.merge as any).mockResolvedValue({ (strategies.author.merge as any).mockResolvedValue({
affectedBookIds: [10, 20], affectedBookIds: [10, 20],
imagePromoted: true, imagePromoted: true,
@@ -615,6 +620,7 @@ describe('EntityManagerService', () => {
expect(repo.deleteDismissedPairsForEntity).toHaveBeenCalledWith('author', 3); expect(repo.deleteDismissedPairsForEntity).toHaveBeenCalledWith('author', 3);
expect(duplicateCompute.invalidateCandidatesForEntities).toHaveBeenCalledTimes(1); expect(duplicateCompute.invalidateCandidatesForEntities).toHaveBeenCalledTimes(1);
expect(duplicateCompute.invalidateCandidatesForEntities).toHaveBeenCalledWith('author', [2, 3]); expect(duplicateCompute.invalidateCandidatesForEntities).toHaveBeenCalledWith('author', [2, 3]);
expect(metadataScoreService.calculateAndSaveMany).toHaveBeenCalledWith([10, 20]);
expect(result).toEqual({ expect(result).toEqual({
targetId: 1, targetId: 1,
mergedIds: [2, 3], mergedIds: [2, 3],
@@ -624,6 +630,14 @@ describe('EntityManagerService', () => {
}); });
}); });
it('propagates score persistence failures', async () => {
const { service, strategies, metadataScoreService } = makeService();
(strategies.author.merge as any).mockResolvedValue({ affectedBookIds: [10] });
metadataScoreService.calculateAndSaveMany.mockRejectedValue(new Error('score failed'));
await expect(service.merge('author', mockUser, 1, [2], false)).rejects.toThrow('score failed');
});
it('invalidates candidate rows once for all merged source IDs', async () => { it('invalidates candidate rows once for all merged source IDs', async () => {
const { service, strategies, duplicateCompute } = makeService(); const { service, strategies, duplicateCompute } = makeService();
(strategies.author.merge as any).mockResolvedValue({ affectedBookIds: [] }); (strategies.author.merge as any).mockResolvedValue({ affectedBookIds: [] });
@@ -18,6 +18,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { FileWriteService } from '../file-write/file-write.service'; import { FileWriteService } from '../file-write/file-write.service';
import { LibraryService } from '../library/library.service'; import { LibraryService } from '../library/library.service';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { EntityManagerRepository } from './entity-manager.repository'; import { EntityManagerRepository } from './entity-manager.repository';
import { DuplicateComputeService } from './duplicate-compute.service'; import { DuplicateComputeService } from './duplicate-compute.service';
import type { EntityBookScope, EntityStrategy, RawCandidatePair } from './strategies/entity-strategy.interface'; import type { EntityBookScope, EntityStrategy, RawCandidatePair } from './strategies/entity-strategy.interface';
@@ -48,6 +49,7 @@ export class EntityManagerService {
private readonly repo: EntityManagerRepository, private readonly repo: EntityManagerRepository,
private readonly libraryService: LibraryService, private readonly libraryService: LibraryService,
private readonly fileWriteService: FileWriteService, private readonly fileWriteService: FileWriteService,
private readonly metadataScoreService: MetadataScoreService,
private readonly duplicateCompute: DuplicateComputeService, private readonly duplicateCompute: DuplicateComputeService,
authorStrategy: AuthorStrategy, authorStrategy: AuthorStrategy,
genreStrategy: GenreStrategy, genreStrategy: GenreStrategy,
@@ -260,6 +262,7 @@ export class EntityManagerService {
if (writeFiles) { if (writeFiles) {
this.scheduleWrites(result.affectedBookIds, user.id); this.scheduleWrites(result.affectedBookIds, user.id);
} }
await this.metadataScoreService.calculateAndSaveMany(result.affectedBookIds);
this.logger.log( this.logger.log(
`[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} affectedBooks=${result.affectedBookIds.length} - merge completed`, `[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} affectedBooks=${result.affectedBookIds.length} - merge completed`,
@@ -294,6 +297,7 @@ export class EntityManagerService {
if (writeFiles) { if (writeFiles) {
this.scheduleWrites(result.affectedBookIds, user.id); this.scheduleWrites(result.affectedBookIds, user.id);
} }
await this.metadataScoreService.calculateAndSaveMany(result.affectedBookIds);
this.logger.log( this.logger.log(
`[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} implicitMerge=${result.wasImplicitMerge} - rename completed`, `[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} implicitMerge=${result.wasImplicitMerge} - rename completed`,
@@ -343,6 +347,7 @@ export class EntityManagerService {
if (writeFiles) { if (writeFiles) {
this.scheduleWrites(result.affectedBookIds, user.id); this.scheduleWrites(result.affectedBookIds, user.id);
} }
await this.metadataScoreService.calculateAndSaveMany(result.affectedBookIds);
this.logger.log( this.logger.log(
`[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} affectedBooks=${result.affectedBookIds.length} - delete completed`, `[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} affectedBooks=${result.affectedBookIds.length} - delete completed`,
@@ -403,6 +408,7 @@ export class EntityManagerService {
if (writeFiles) { if (writeFiles) {
this.scheduleWrites(result.affectedBookIds, user.id); this.scheduleWrites(result.affectedBookIds, user.id);
} }
await this.metadataScoreService.calculateAndSaveMany(result.affectedBookIds);
this.logger.log( this.logger.log(
`[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} newEntities=${result.newEntities.length} - split completed`, `[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} newEntities=${result.newEntities.length} - split completed`,
@@ -38,6 +38,7 @@ function makeWeights(): MetadataScoreWeights {
openLibraryId: 1, openLibraryId: 1,
itunesId: 1, itunesId: 1,
koboId: 1, koboId: 1,
aladinId: 1,
}; };
} }
@@ -130,6 +131,44 @@ describe('MetadataScoreService', () => {
expect(repo.updateMetadataScore).not.toHaveBeenCalled(); expect(repo.updateMetadataScore).not.toHaveBeenCalled();
}); });
it('serializes concurrent score calculations for the same book', async () => {
const { service, repo, scorer, appSettings } = makeService();
const firstLoad = deferred<Record<string, unknown>>();
const firstData = { title: 'Before' };
const secondData = { title: 'After' };
repo.loadScoreData.mockReturnValueOnce(firstLoad.promise).mockResolvedValueOnce(secondData);
appSettings.getMetadataScoreWeights.mockResolvedValue(makeWeights());
scorer.compute.mockReturnValueOnce(20).mockReturnValueOnce(90);
const firstCalculation = service.calculateAndSave(12);
const secondCalculation = service.calculateAndSave(12);
for (let attempt = 0; attempt < 5 && repo.loadScoreData.mock.calls.length === 0; attempt++) {
await Promise.resolve();
}
expect(repo.loadScoreData).toHaveBeenCalledTimes(1);
firstLoad.resolve(firstData);
await Promise.all([firstCalculation, secondCalculation]);
expect(repo.loadScoreData).toHaveBeenCalledTimes(2);
expect(repo.updateMetadataScore).toHaveBeenNthCalledWith(1, 12, 20);
expect(repo.updateMetadataScore).toHaveBeenNthCalledWith(2, 12, 90);
});
it('recalculates unique books in bulk with one weights lookup', async () => {
const { service, repo, scorer, appSettings } = makeService();
repo.loadScoreData.mockResolvedValue({ title: 'Book' });
appSettings.getMetadataScoreWeights.mockResolvedValue(makeWeights());
scorer.compute.mockReturnValue(75);
await service.calculateAndSaveMany([3, 5, 3, 0]);
expect(appSettings.getMetadataScoreWeights).toHaveBeenCalledTimes(1);
expect(repo.loadScoreData).toHaveBeenCalledTimes(2);
expect(repo.updateMetadataScore).toHaveBeenCalledWith(3, 75);
expect(repo.updateMetadataScore).toHaveBeenCalledWith(5, 75);
});
it('starts manual recalculation and reports completed status with success/failure counters', async () => { it('starts manual recalculation and reports completed status with success/failure counters', async () => {
const { service, repo, scorer, appSettings } = makeService(); const { service, repo, scorer, appSettings } = makeService();
const weights = makeWeights(); const weights = makeWeights();
@@ -7,6 +7,7 @@ import { MetadataScoreScorer, type ScoreData } from './metadata-score.scorer';
const RECALCULATION_PAGE_SIZE = 200; const RECALCULATION_PAGE_SIZE = 200;
const RECALCULATION_WRITE_CONCURRENCY = 25; const RECALCULATION_WRITE_CONCURRENCY = 25;
const ON_DEMAND_WRITE_CONCURRENCY = 25;
const RECALCULATION_EVENT = 'metadata_score.recalculate_all'; const RECALCULATION_EVENT = 'metadata_score.recalculate_all';
const RECALCULATION_BOOK_EVENT = 'metadata_score.recalculate_book'; const RECALCULATION_BOOK_EVENT = 'metadata_score.recalculate_book';
@@ -31,6 +32,7 @@ export type MetadataRecalculationStatus = {
@Injectable() @Injectable()
export class MetadataScoreService { export class MetadataScoreService {
private readonly logger = new Logger(MetadataScoreService.name); private readonly logger = new Logger(MetadataScoreService.name);
private readonly pendingBookCalculations = new Map<number, Promise<void>>();
private runningPromise: Promise<void> | null = null; private runningPromise: Promise<void> | null = null;
private status: MetadataRecalculationStatus = this.makeInitialStatus(); private status: MetadataRecalculationStatus = this.makeInitialStatus();
@@ -66,13 +68,43 @@ export class MetadataScoreService {
return { started: true, status: this.getRecalculationStatus() }; return { started: true, status: this.getRecalculationStatus() };
} }
async calculateAndSave(bookId: number): Promise<void> { calculateAndSave(bookId: number): Promise<void> {
const [data, weights] = await Promise.all([this.repo.loadScoreData(bookId), this.appSettings.getMetadataScoreWeights()]); return this.enqueueBookCalculation(bookId, async () => {
const weights = await this.appSettings.getMetadataScoreWeights();
await this.calculateAndSaveWithWeights(bookId, weights);
});
}
async calculateAndSaveMany(bookIds: number[]): Promise<void> {
const uniqueBookIds = [...new Set(bookIds)].filter((bookId) => Number.isInteger(bookId) && bookId > 0);
if (uniqueBookIds.length === 0) return;
const weights = await this.appSettings.getMetadataScoreWeights();
for (let index = 0; index < uniqueBookIds.length; index += ON_DEMAND_WRITE_CONCURRENCY) {
const chunk = uniqueBookIds.slice(index, index + ON_DEMAND_WRITE_CONCURRENCY);
await Promise.all(chunk.map((bookId) => this.enqueueBookCalculation(bookId, () => this.calculateAndSaveWithWeights(bookId, weights))));
}
}
private async calculateAndSaveWithWeights(bookId: number, weights: MetadataScoreWeights): Promise<void> {
const data = await this.repo.loadScoreData(bookId);
if (!data) return; if (!data) return;
const score = this.scorer.compute(data, weights); const score = this.scorer.compute(data, weights);
await this.repo.updateMetadataScore(bookId, score); await this.repo.updateMetadataScore(bookId, score);
} }
private enqueueBookCalculation(bookId: number, calculate: () => Promise<void>): Promise<void> {
const previous = this.pendingBookCalculations.get(bookId) ?? Promise.resolve();
const calculation = previous.catch(() => undefined).then(calculate);
this.pendingBookCalculations.set(bookId, calculation);
return calculation.finally(() => {
if (this.pendingBookCalculations.get(bookId) === calculation) {
this.pendingBookCalculations.delete(bookId);
}
});
}
private async runRecalculation(trigger: MetadataRecalculationTrigger): Promise<void> { private async runRecalculation(trigger: MetadataRecalculationTrigger): Promise<void> {
const startedAt = Date.now(); const startedAt = Date.now();
this.status = { this.status = {
@@ -452,7 +452,7 @@ describe('MetadataService', () => {
await expect(service.extractAndSave(15, '/books/a.pdf', 'pdf')).rejects.toThrow('bad metadata'); await expect(service.extractAndSave(15, '/books/a.pdf', 'pdf')).rejects.toThrow('bad metadata');
}); });
it('extractAndSave logs warning when score calculation or embedding fails', async () => { it('extractAndSave propagates score calculation failures after persisting metadata', async () => {
const { db } = makeDb(); const { db } = makeDb();
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const scoreService = { const scoreService = {
@@ -492,12 +492,12 @@ describe('MetadataService', () => {
coverBuffer: null, coverBuffer: null,
}); });
await service.extractAndSave(44, '/tmp/warn-book.pdf', 'pdf'); await expect(service.extractAndSave(44, '/tmp/warn-book.pdf', 'pdf')).rejects.toThrow('score failed');
await Promise.resolve(); await Promise.resolve();
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(44); expect(scoreService.calculateAndSave).toHaveBeenCalledWith(44);
expect(failingEmbedder.embedBook).toHaveBeenCalledWith(44); expect(failingEmbedder.embedBook).toHaveBeenCalledWith(44);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[metadata.score_calculation] [fail]')); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[metadata.extract_and_save] [fail]'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[metadata.embedding] [fail]')); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[metadata.embedding] [fail]'));
}); });
+12 -11
View File
@@ -110,11 +110,7 @@ export class MetadataService {
await Promise.all([this.persistMetadata(bookId, data, format), data.cover ? this.persistCover(bookId, data.cover, true) : Promise.resolve()]); await Promise.all([this.persistMetadata(bookId, data, format), data.cover ? this.persistCover(bookId, data.cover, true) : Promise.resolve()]);
this.scoreService.calculateAndSave(bookId).catch((error: Error) => { await this.scoreService.calculateAndSave(bookId);
this.logger.warn(
`[metadata.score_calculation] [fail] bookId=${bookId} errorClass=${error.name} error="${sanitizeLogValue(error.message)}" - metadata score calculation failed`,
);
});
this.logger.debug( this.logger.debug(
`[${event}] [end] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} coverExtracted=${data.cover != null} - metadata extraction completed`, `[${event}] [end] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} coverExtracted=${data.cover != null} - metadata extraction completed`,
@@ -194,8 +190,6 @@ export class MetadataService {
} }
await this.persistCover(bookId, buffer, true); await this.persistCover(bookId, buffer, true);
this.logger.debug(`[${event}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} saved=true - cover download completed`);
return true;
} catch (error) { } catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error'; const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error)); const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
@@ -204,10 +198,15 @@ export class MetadataService {
); );
return false; return false;
} }
await this.scoreService.calculateAndSave(bookId);
this.logger.debug(`[${event}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} saved=true - cover download completed`);
return true;
} }
async saveExtractedCoverBytes(bookId: number, bytes: Buffer): Promise<void> { async saveExtractedCoverBytes(bookId: number, bytes: Buffer): Promise<void> {
await this.persistCover(bookId, bytes, true); await this.persistCover(bookId, bytes, true);
await this.scoreService.calculateAndSave(bookId);
} }
async refreshCoverForBook(bookId: number, absolutePath: string, format: string): Promise<boolean> { async refreshCoverForBook(bookId: number, absolutePath: string, format: string): Promise<boolean> {
@@ -235,10 +234,6 @@ export class MetadataService {
return false; return false;
} }
await this.persistCover(bookId, data.cover, false); await this.persistCover(bookId, data.cover, false);
this.logger.debug(
`[${event}] [end] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} refreshed=true - cover refresh completed`,
);
return true;
} catch (error) { } catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error'; const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error)); const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
@@ -247,6 +242,12 @@ export class MetadataService {
); );
return false; return false;
} }
await this.scoreService.calculateAndSave(bookId);
this.logger.debug(
`[${event}] [end] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} refreshed=true - cover refresh completed`,
);
return true;
} }
// ── Audio helpers ──────────────────────────────────────────────────────────── // ── Audio helpers ────────────────────────────────────────────────────────────