mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
+4
-4
@@ -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);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user