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 { LibraryModule } from '../library/library.module';
import { MetadataModule } from '../metadata/metadata.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { NotificationModule } from '../notification/notification.module';
import { AuthorImageStorageService } from './author-image-storage.service';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
@@ -33,6 +34,7 @@ const AUTHOR_PROVIDER_CLASSES = [AudnexusAuthorMetadataProvider];
LibraryModule,
AppSettingsModule,
MetadataModule,
MetadataScoreModule,
forwardRef(() => NotificationModule),
AuthModule,
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.countDistinctBooks([])).resolves.toBe(0);
await expect(repo.findBookIdsByAuthorIds([])).resolves.toEqual([]);
await expect(repo.findRelatedLibraryIds([])).resolves.toEqual([]);
await expect(repo.mergeAuthors(1, [])).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 () => {
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);
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]);
});
@@ -249,6 +249,12 @@ export class AuthorsRepository {
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[]> {
if (authorIds.length === 0) return [];
const rows = await this.db
@@ -22,6 +22,7 @@ describe('AuthorsService', () => {
updateAuthorById: vi.fn(),
findVisibleAuthorIds: vi.fn(),
countDistinctBooks: vi.fn(),
findBookIdsByAuthorIds: vi.fn(),
mergeAuthors: vi.fn(),
deleteAuthors: vi.fn(),
findRelatedLibraryIds: vi.fn(),
@@ -67,6 +68,9 @@ describe('AuthorsService', () => {
const appSettings = {
getAuthorsAutoEnrichmentWriteMode: vi.fn(),
};
const metadataScoreService = {
calculateAndSaveMany: vi.fn(),
};
let service: AuthorsService;
@@ -81,6 +85,7 @@ describe('AuthorsService', () => {
authorImageStorage as any,
enrichmentExecutor as any,
enrichmentOrchestrator as any,
metadataScoreService as any,
);
libraryService.findAll.mockResolvedValue([{ id: 1 }, { id: 2 }]);
authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue(null);
@@ -88,6 +93,7 @@ describe('AuthorsService', () => {
enrichmentOrchestrator.schedule.mockResolvedValue(1);
enrichmentOrchestrator.backfillLinkedAuthors.mockResolvedValue(8);
appSettings.getAuthorsAutoEnrichmentWriteMode.mockResolvedValue('missing_only');
metadataScoreService.calculateAndSaveMany.mockResolvedValue(undefined);
vi.mocked(assembleBookCards).mockReturnValue([]);
});
@@ -114,12 +120,13 @@ describe('AuthorsService', () => {
it('delete removes authors and returns impacted count', async () => {
authorsRepo.findVisibleAuthorIds.mockResolvedValue([10, 11]);
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1, 2]);
authorsRepo.countDistinctBooks.mockResolvedValue(6);
authorsRepo.findBookIdsByAuthorIds.mockResolvedValue([1, 2, 3, 4, 5, 6]);
authorsRepo.deleteAuthors.mockResolvedValue(undefined);
const result = await service.delete(reqUser(7, true), { authorIds: [10, 11, 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.affectedBookCount).toBe(6);
});
@@ -127,7 +134,7 @@ describe('AuthorsService', () => {
it('merge deduplicates sources, merges, and returns impacted count', async () => {
authorsRepo.findVisibleAuthorIds.mockResolvedValue([10, 11, 12]);
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.findById.mockResolvedValue({
id: 10,
@@ -141,6 +148,7 @@ describe('AuthorsService', () => {
const result = await service.merge(reqUser(7, true), { targetAuthorId: 10, sourceAuthorIds: [10, 11, 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(result.mergedAuthorIds).toEqual([11, 12]);
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 { LibraryService } from '../library/library.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 { AUTHOR_ENRICHMENT_REASONS } from './author-enrichment-reasons';
import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service';
@@ -50,6 +51,7 @@ export class AuthorsService {
private readonly authorImageStorage: AuthorImageStorageService,
private readonly enrichmentExecutor: AuthorEnrichmentExecutorService,
private readonly enrichmentOrchestrator: AuthorEnrichmentOrchestratorService,
private readonly metadataScoreService: MetadataScoreService,
) {}
private assertPaginationWindow(page: number, size: number): void {
@@ -268,18 +270,19 @@ export class AuthorsService {
const allAuthorIds = [dto.targetAuthorId, ...uniqueSourceIds];
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.metadataScoreService.calculateAndSaveMany(affectedBookIds);
await this.enrichmentOrchestrator.schedule(dto.targetAuthorId, AUTHOR_ENRICHMENT_REASONS.AUTHOR_MERGE_TARGET);
const target = await this.findOne(user, dto.targetAuthorId);
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 {
target,
mergedAuthorIds: uniqueSourceIds,
affectedBookCount,
affectedBookCount: affectedBookIds.length,
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
@@ -303,15 +306,16 @@ export class AuthorsService {
const authorIds = [...new Set(dto.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.metadataScoreService.calculateAndSaveMany(affectedBookIds);
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 {
deletedAuthorIds: authorIds,
affectedBookCount,
affectedBookCount: affectedBookIds.length,
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
@@ -48,6 +48,9 @@ function makeService() {
replaceNarrators: vi.fn().mockResolvedValue(undefined),
upsertComicMetadata: vi.fn().mockResolvedValue(undefined),
};
const metadataScoreService = {
calculateAndSave: vi.fn().mockResolvedValue(undefined),
};
const bookReadService = {
replaceCommunityRatings: vi.fn().mockResolvedValue(undefined),
};
@@ -82,6 +85,7 @@ function makeService() {
libraryService as never,
appSettings as never,
metadataService as never,
metadataScoreService as never,
bookReadService as never,
validator as never,
storage as never,
@@ -101,6 +105,7 @@ function makeService() {
libraryService,
appSettings,
metadataService,
metadataScoreService,
bookReadService,
validator,
storage,
@@ -1066,7 +1071,7 @@ describe('BookDockFinalizeService', () => {
});
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 = {
set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined),
@@ -1109,6 +1114,20 @@ describe('BookDockFinalizeService', () => {
);
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(15, [{ name: 'Author A', sortName: null }]);
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 () => {
@@ -42,6 +42,7 @@ import { bookMetadata, libraries, libraryFolders } from '../../db/schema';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { LibraryService } from '../library/library.service';
import { MetadataService } from '../metadata/metadata.service';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { UploadProcessorService } from '../upload/upload-processor.service';
import { UploadStorageService } from '../upload/upload-storage.service';
import { UploadValidatorService } from '../upload/upload-validator.service';
@@ -133,6 +134,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
private readonly libraryService: LibraryService,
private readonly appSettings: AppSettingsService,
private readonly metadataService: MetadataService,
private readonly metadataScoreService: MetadataScoreService,
private readonly bookReadService: BookReadService,
private readonly validator: UploadValidatorService,
private readonly storage: UploadStorageService,
@@ -855,6 +857,8 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
audio.narrators.map((name) => ({ name, sortName: null })),
);
}
await this.metadataScoreService.calculateAndSave(bookId);
}
private async cleanupBookDockRecord(row: BookDockFileRow): Promise<void> {
@@ -9,6 +9,7 @@ import { BookModule } from '../book/book.module';
import { LibraryModule } from '../library/library.module';
import { MetadataFetchModule } from '../metadata-fetch/metadata-fetch.module';
import { MetadataModule } from '../metadata/metadata.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { NotificationModule } from '../notification/notification.module';
import { UploadModule } from '../upload/upload.module';
import { BookDockController } from './book-dock.controller';
@@ -30,6 +31,7 @@ import { BookDockRepository } from './book-dock.repository';
LibraryModule,
MetadataFetchModule,
MetadataModule,
MetadataScoreModule,
forwardRef(() => NotificationModule),
AppSettingsModule,
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();
session.addToTotal(1);
bookReadService.findById.mockResolvedValue({
@@ -488,9 +488,9 @@ describe('BookMetadataFetchOrchestratorService', () => {
await (service as any).processOne(90, 'Book');
expect(queueRepo.markDone).toHaveBeenCalledWith(90);
expect(queueRepo.markFailed).not.toHaveBeenCalled();
expect(session.getSnapshot().sessionDone).toBe(1);
expect(queueRepo.markDone).not.toHaveBeenCalled();
expect(queueRepo.markFailed).toHaveBeenCalledWith(90, 'score delayed', undefined);
expect(session.getSnapshot().sessionDone).toBe(0);
expect(session.getSnapshot().currentItemName).toBeNull();
});
@@ -257,13 +257,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
await this.persistResolved(bookId, resolved, providerIds, authorRows, genreRows, narratorRows);
this.scoreService
.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`,
),
);
await this.scoreService.calculateAndSave(bookId);
this.logger.debug(`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} - metadata fetch completed`);
await this.queueRepo.markDone(bookId);
+6 -5
View File
@@ -178,7 +178,11 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
};
const scoreService = {
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 = {
embedBook: vi.fn().mockResolvedValue(undefined),
};
@@ -3344,18 +3348,15 @@ describe('BookService', () => {
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 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);
scoreService.calculateAndSave.mockRejectedValue(new Error('score exploded'));
await service.bulkSetRating([3], 4, user);
await Promise.resolve();
await expect(service.bulkSetRating([3], 4, user)).rejects.toThrow('score exploded');
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 () => {
+8 -9
View File
@@ -1772,7 +1772,7 @@ export class BookService {
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);
@@ -2206,7 +2206,7 @@ export class BookService {
const updatableIds = bookIds.filter((bookId) => !lockedIds.has(bookId));
if (updatableIds.length > 0) {
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, {
userId: user.id,
bookIds: updatableIds,
@@ -2273,7 +2273,7 @@ export class BookService {
}
});
}
this.triggerPostMetadataUpdateEffects(updatableIds, user.id);
await this.triggerPostMetadataUpdateEffects(updatableIds, user.id);
}
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} field=${field} updated=${updatableIds.length} skippedLocked=${lockedIds.size} durationMs=${Date.now() - startedAt} - bulk set metadata completed`,
@@ -2300,7 +2300,7 @@ export class BookService {
await this.metadataService.replaceTags(bookId, next, { executor: tx });
}
});
this.triggerPostMetadataUpdateEffects(bookIds, user.id);
await this.triggerPostMetadataUpdateEffects(bookIds, user.id);
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} mode=${mode} tagCount=${tags.length} durationMs=${Date.now() - startedAt} - bulk update tags completed`,
@@ -2495,7 +2495,7 @@ export class BookService {
}
if (allUpdatedBookIds.size > 0) {
this.triggerPostMetadataUpdateEffects([...allUpdatedBookIds], user.id);
await this.triggerPostMetadataUpdateEffects([...allUpdatedBookIds], user.id);
}
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) {
this.fileWriteService?.scheduleWrite(bookId, 'auto', 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(
+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 { LibraryModule } from '../library/library.module';
import { MetadataPreferencesModule } from '../metadata-preferences/metadata-preferences.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { COVER_PROVIDERS } from './constants';
import { CoverController } from './cover.controller';
import { CoverService } from './cover.service';
@@ -16,7 +17,7 @@ import { CoverProviderRegistry } from './provider-registry';
const PROVIDER_CLASSES = [DuckDuckGoCoverProvider, ITunesCoverProvider, AudiobookCoversCoverProvider];
@Module({
imports: [BookModule, BookMetadataLockModule, FileWriteModule, LibraryModule, MetadataPreferencesModule],
imports: [BookModule, BookMetadataLockModule, FileWriteModule, LibraryModule, MetadataPreferencesModule, MetadataScoreModule],
controllers: [CoverController],
providers: [
...PROVIDER_CLASSES,
+22 -2
View File
@@ -105,6 +105,7 @@ function createService(providerRegistry: CoverProviderRegistry, options?: { asse
{ verifyUserAccess: vi.fn().mockResolvedValue(undefined) } as never,
{ get: vi.fn().mockReturnValue('/tmp/books') } as never,
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 update = vi.fn().mockReturnValue({ set: updateSet });
const mockDb = { insert, update };
const scoreService = {
calculateAndSave: vi.fn().mockResolvedValue(undefined),
};
const service = new CoverService(
mockDb as never,
@@ -125,9 +129,10 @@ function createMutationService(options?: { assertFieldsUnlocked?: ReturnType<typ
{ verifyUserAccess: vi.fn().mockResolvedValue(undefined) } as never,
{ get: vi.fn().mockReturnValue('/tmp/books') } as never,
{ select: vi.fn().mockReturnValue([]) } as unknown as CoverProviderRegistry,
scoreService as never,
);
return { service, mockDb, updateSet };
return { service, mockDb, updateSet, scoreService };
}
describe('CoverService', () => {
@@ -474,7 +479,7 @@ describe('CoverService', () => {
});
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 normalized = Buffer.from('normalized-image');
@@ -510,6 +515,21 @@ describe('CoverService', () => {
);
expect(mockDb.update).toHaveBeenCalledWith(books);
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 () => {
@@ -19,6 +19,7 @@ import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-loc
import { FileWriteService } from '../file-write/file-write.service';
import { LibraryService } from '../library/library.service';
import { generateThumbnail, imageExt, normalizeProgressiveJpeg } from '../metadata/lib/cover';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import {
COVER_CUSTOM_FILE_PREFIX,
COVER_PROXY_MAX_IMAGE_BYTES,
@@ -54,6 +55,7 @@ export class CoverService {
private readonly libraryService: LibraryService,
private readonly config: ConfigService,
private readonly providerRegistry: CoverProviderRegistry,
private readonly metadataScoreService: MetadataScoreService,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
@@ -299,6 +301,7 @@ export class CoverService {
.values({ bookId, 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.metadataScoreService.calculateAndSave(bookId);
}
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 { FileWriteModule } from '../file-write/file-write.module';
import { LibraryModule } from '../library/library.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { EntityManagerController } from './entity-manager.controller';
import { EntityManagerRepository } from './entity-manager.repository';
import { EntityManagerService } from './entity-manager.service';
@@ -16,7 +17,7 @@ import { SeriesStrategy } from './strategies/series.strategy';
import { TagStrategy } from './strategies/tag.strategy';
@Module({
imports: [AuthorsModule, FileWriteModule, LibraryModule],
imports: [AuthorsModule, FileWriteModule, LibraryModule, MetadataScoreModule],
controllers: [EntityManagerController],
providers: [
EntityManagerService,
@@ -43,6 +43,9 @@ function makeService() {
const fileWriteService = {
scheduleWrite: vi.fn(),
};
const metadataScoreService = {
calculateAndSaveMany: vi.fn().mockResolvedValue(undefined),
};
const duplicateCompute = {
readCandidatePairs: vi.fn().mockResolvedValue([]),
@@ -63,6 +66,7 @@ function makeService() {
repo as any,
libraryService as any,
fileWriteService as any,
metadataScoreService as any,
duplicateCompute as any,
authorStrategy as any,
genreStrategy as any,
@@ -78,6 +82,7 @@ function makeService() {
repo,
libraryService,
fileWriteService,
metadataScoreService,
duplicateCompute,
strategies: {
author: authorStrategy,
@@ -600,7 +605,7 @@ describe('EntityManagerService', () => {
describe('merge', () => {
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({
affectedBookIds: [10, 20],
imagePromoted: true,
@@ -615,6 +620,7 @@ describe('EntityManagerService', () => {
expect(repo.deleteDismissedPairsForEntity).toHaveBeenCalledWith('author', 3);
expect(duplicateCompute.invalidateCandidatesForEntities).toHaveBeenCalledTimes(1);
expect(duplicateCompute.invalidateCandidatesForEntities).toHaveBeenCalledWith('author', [2, 3]);
expect(metadataScoreService.calculateAndSaveMany).toHaveBeenCalledWith([10, 20]);
expect(result).toEqual({
targetId: 1,
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 () => {
const { service, strategies, duplicateCompute } = makeService();
(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 { FileWriteService } from '../file-write/file-write.service';
import { LibraryService } from '../library/library.service';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { EntityManagerRepository } from './entity-manager.repository';
import { DuplicateComputeService } from './duplicate-compute.service';
import type { EntityBookScope, EntityStrategy, RawCandidatePair } from './strategies/entity-strategy.interface';
@@ -48,6 +49,7 @@ export class EntityManagerService {
private readonly repo: EntityManagerRepository,
private readonly libraryService: LibraryService,
private readonly fileWriteService: FileWriteService,
private readonly metadataScoreService: MetadataScoreService,
private readonly duplicateCompute: DuplicateComputeService,
authorStrategy: AuthorStrategy,
genreStrategy: GenreStrategy,
@@ -260,6 +262,7 @@ export class EntityManagerService {
if (writeFiles) {
this.scheduleWrites(result.affectedBookIds, user.id);
}
await this.metadataScoreService.calculateAndSaveMany(result.affectedBookIds);
this.logger.log(
`[${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) {
this.scheduleWrites(result.affectedBookIds, user.id);
}
await this.metadataScoreService.calculateAndSaveMany(result.affectedBookIds);
this.logger.log(
`[${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) {
this.scheduleWrites(result.affectedBookIds, user.id);
}
await this.metadataScoreService.calculateAndSaveMany(result.affectedBookIds);
this.logger.log(
`[${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) {
this.scheduleWrites(result.affectedBookIds, user.id);
}
await this.metadataScoreService.calculateAndSaveMany(result.affectedBookIds);
this.logger.log(
`[${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,
itunesId: 1,
koboId: 1,
aladinId: 1,
};
}
@@ -130,6 +131,44 @@ describe('MetadataScoreService', () => {
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 () => {
const { service, repo, scorer, appSettings } = makeService();
const weights = makeWeights();
@@ -7,6 +7,7 @@ import { MetadataScoreScorer, type ScoreData } from './metadata-score.scorer';
const RECALCULATION_PAGE_SIZE = 200;
const RECALCULATION_WRITE_CONCURRENCY = 25;
const ON_DEMAND_WRITE_CONCURRENCY = 25;
const RECALCULATION_EVENT = 'metadata_score.recalculate_all';
const RECALCULATION_BOOK_EVENT = 'metadata_score.recalculate_book';
@@ -31,6 +32,7 @@ export type MetadataRecalculationStatus = {
@Injectable()
export class MetadataScoreService {
private readonly logger = new Logger(MetadataScoreService.name);
private readonly pendingBookCalculations = new Map<number, Promise<void>>();
private runningPromise: Promise<void> | null = null;
private status: MetadataRecalculationStatus = this.makeInitialStatus();
@@ -66,13 +68,43 @@ export class MetadataScoreService {
return { started: true, status: this.getRecalculationStatus() };
}
async calculateAndSave(bookId: number): Promise<void> {
const [data, weights] = await Promise.all([this.repo.loadScoreData(bookId), this.appSettings.getMetadataScoreWeights()]);
calculateAndSave(bookId: number): Promise<void> {
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;
const score = this.scorer.compute(data, weights);
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> {
const startedAt = Date.now();
this.status = {
@@ -452,7 +452,7 @@ describe('MetadataService', () => {
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 warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const scoreService = {
@@ -492,12 +492,12 @@ describe('MetadataService', () => {
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();
expect(scoreService.calculateAndSave).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]'));
});
+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()]);
this.scoreService.calculateAndSave(bookId).catch((error: Error) => {
this.logger.warn(
`[metadata.score_calculation] [fail] bookId=${bookId} errorClass=${error.name} error="${sanitizeLogValue(error.message)}" - metadata score calculation failed`,
);
});
await this.scoreService.calculateAndSave(bookId);
this.logger.debug(
`[${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);
this.logger.debug(`[${event}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} saved=true - cover download completed`);
return true;
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
@@ -204,10 +198,15 @@ export class MetadataService {
);
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> {
await this.persistCover(bookId, bytes, true);
await this.scoreService.calculateAndSave(bookId);
}
async refreshCoverForBook(bookId: number, absolutePath: string, format: string): Promise<boolean> {
@@ -235,10 +234,6 @@ export class MetadataService {
return 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) {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
@@ -247,6 +242,12 @@ export class MetadataService {
);
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 ────────────────────────────────────────────────────────────