fix(metadata): harden preference concurrency and score recalculation

This commit is contained in:
neon
2026-03-31 04:50:04 -06:00
parent 8347f3e26e
commit 32bbba9aa9
26 changed files with 1432 additions and 299 deletions
@@ -37,6 +37,8 @@ export const ALL_METADATA_FIELDS: MetadataField[] = [
export type MergeStrategy = "fillMissing" | "overwrite" | "overwriteIfProvided";
export type GenreMergeMode = "firstProvider" | "merge";
export const MERGE_STRATEGIES: MergeStrategy[] = ["fillMissing", "overwrite", "overwriteIfProvided"];
export const GENRE_MERGE_MODES: GenreMergeMode[] = ["firstProvider", "merge"];
export interface FieldPreference {
enabled: boolean;
@@ -85,6 +87,7 @@ export interface SimpleProviderConfig {
}
export type ITunesCoverResolution = "standard" | "high";
export const ITUNES_COVER_RESOLUTIONS: ITunesCoverResolution[] = ["standard", "high"];
export interface ITunesProviderConfig {
enabled: boolean;
@@ -61,7 +61,6 @@ describe('Architecture boundaries', () => {
'src/modules/metadata-fetch/metadata-fetch.service.ts',
'src/modules/metadata-preferences/metadata-preferences.service.ts',
'src/modules/metadata-preferences/provider-config.service.ts',
'src/modules/metadata-score/metadata-score.service.ts',
'src/modules/opds/opds-book.service.ts',
'src/modules/opds/opds-user.service.ts',
'src/modules/scanner/file-watcher.service.ts',
@@ -11,11 +11,8 @@ import {
validateSync,
} from 'class-validator';
import { plainToInstance, Type } from 'class-transformer';
import { ALL_METADATA_FIELDS, MetadataProviderKey } from '@projectx/types';
import type { GenreMergeMode, MetadataField, MergeStrategy } from '@projectx/types';
const MERGE_STRATEGIES: MergeStrategy[] = ['fillMissing', 'overwrite', 'overwriteIfProvided'];
const GENRE_MERGE_MODES: GenreMergeMode[] = ['firstProvider', 'merge'];
import { ALL_METADATA_FIELDS, GENRE_MERGE_MODES, MERGE_STRATEGIES, MetadataProviderKey } from '@projectx/types';
import type { GenreMergeMode, MergeStrategy, MetadataField } from '@projectx/types';
const PROVIDER_KEYS = Object.values(MetadataProviderKey);
export class FieldPreferenceDto {
@@ -1,5 +1,6 @@
import { IsBoolean, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ITUNES_COVER_RESOLUTIONS } from '@projectx/types';
export class GoogleProviderConfigDto {
@IsOptional() @IsBoolean() enabled?: boolean;
@@ -16,8 +17,6 @@ export class SimpleProviderConfigDto {
@IsOptional() @IsBoolean() enabled?: boolean;
}
const ITUNES_COVER_RESOLUTIONS = ['standard', 'high'] as const;
export class ITunesProviderConfigDto {
@IsOptional() @IsBoolean() enabled?: boolean;
@IsOptional() @IsIn(ITUNES_COVER_RESOLUTIONS) coverResolution?: (typeof ITUNES_COVER_RESOLUTIONS)[number];
@@ -164,10 +164,4 @@ describe('MetadataPreferenceResolver', () => {
expect(result.fields.title.providers).toEqual([MetadataProviderKey.GOOGLE]);
expect(result.fields.subtitle.providers).toEqual([MetadataProviderKey.GOOGLE]);
});
it('resolves a single field from resolved preferences', () => {
const prefs = resolver.getDefaultPreferences();
expect(resolver.resolveField(prefs, 'authors')).toEqual(prefs.fields.authors);
});
});
@@ -3,11 +3,12 @@ import {
ALL_METADATA_FIELDS,
FieldPreference,
FieldPreferenceOverrides,
GenreMergeMode,
GENRE_MERGE_MODES,
MetadataFetchPreferences,
MetadataFetchOptions,
MetadataField,
MetadataProviderKey,
MERGE_STRATEGIES,
MergeStrategy,
} from '@projectx/types';
@@ -19,8 +20,8 @@ const DEFAULT_PROVIDER_ORDER: MetadataProviderKey[] = [
];
const DEFAULT_MERGE_STRATEGY: MergeStrategy = 'overwriteIfProvided';
const MERGE_STRATEGIES: Set<MergeStrategy> = new Set(['fillMissing', 'overwrite', 'overwriteIfProvided']);
const GENRE_MERGE_MODES: Set<GenreMergeMode> = new Set(['firstProvider', 'merge']);
const MERGE_STRATEGY_SET: Set<MergeStrategy> = new Set(MERGE_STRATEGIES);
const GENRE_MERGE_MODE_SET = new Set(GENRE_MERGE_MODES);
const PROVIDERS_WITH_ITUNES: MetadataProviderKey[] = [
MetadataProviderKey.GOODREADS,
@@ -103,10 +104,6 @@ export class MetadataPreferenceResolver {
return { fields, options };
}
resolveField(preferences: MetadataFetchPreferences, field: MetadataField): FieldPreference {
return preferences.fields[field];
}
private normalizeFieldPreference(value: unknown, fallback: FieldPreference): FieldPreference {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return { ...fallback, providers: [...fallback.providers] };
@@ -118,7 +115,7 @@ export class MetadataPreferenceResolver {
Array.isArray(candidate.providers) && candidate.providers.every((p) => typeof p === 'string')
? [...candidate.providers]
: [...fallback.providers];
const mergeStrategy = MERGE_STRATEGIES.has(candidate.mergeStrategy as MergeStrategy)
const mergeStrategy = MERGE_STRATEGY_SET.has(candidate.mergeStrategy as MergeStrategy)
? (candidate.mergeStrategy as MergeStrategy)
: fallback.mergeStrategy;
@@ -134,7 +131,9 @@ export class MetadataPreferenceResolver {
const genresCandidate: Partial<MetadataFetchOptions['genres']> =
candidate.genres && typeof candidate.genres === 'object' && !Array.isArray(candidate.genres) ? candidate.genres : {};
const mode = GENRE_MERGE_MODES.has(genresCandidate.mode as GenreMergeMode) ? (genresCandidate.mode as GenreMergeMode) : fallback.genres.mode;
const mode = GENRE_MERGE_MODE_SET.has(genresCandidate.mode as MetadataFetchOptions['genres']['mode'])
? (genresCandidate.mode as MetadataFetchOptions['genres']['mode'])
: fallback.genres.mode;
const saveProviderIds = typeof candidate.saveProviderIds === 'boolean' ? candidate.saveProviderIds : fallback.saveProviderIds;
return {
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { Logger, NotFoundException } from '@nestjs/common';
import type { Mocked } from 'vitest';
import { MetadataProviderKey } from '@projectx/types';
@@ -45,11 +45,17 @@ describe('MetadataPreferencesService', () => {
let db: ReturnType<typeof createDb>;
let resolver: Mocked<MetadataPreferenceResolver>;
let service: MetadataPreferencesService;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
db = createDb();
resolver = createResolver();
service = new MetadataPreferencesService(db as never, resolver);
warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it('returns defaults when global preferences are missing', async () => {
@@ -94,6 +100,21 @@ describe('MetadataPreferencesService', () => {
expect(resolver.getDefaultPreferences).toHaveBeenCalled();
expect(prefs.fields.cover.enabled).toBe(true);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[metadata_preferences.global_parse] [fail] key=metadata_fetch_preferences durationMs='),
);
});
it('escapes quotes in parse errors before logging', async () => {
db.query.appSettings.findFirst.mockResolvedValue({ value: '{"fields":{}}' });
const parseSpy = vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
throw new Error('invalid "json" payload');
});
await service.getGlobal();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('error="invalid \\"json\\" payload"'));
parseSpy.mockRestore();
});
it('normalizes and upserts global preferences', async () => {
@@ -160,7 +181,7 @@ describe('MetadataPreferencesService', () => {
},
};
await service.setLibraryOverrides(4, overrides as never);
await service.setLibraryOverrides(4, overrides);
expect(db.__updateChain.set).toHaveBeenCalledWith({ metadataFetchPreferences: overrides });
});
@@ -1,4 +1,4 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { FieldPreferenceOverrides, LibraryMetadataPreferences, MetadataFetchPreferences } from '@projectx/types';
@@ -13,12 +13,15 @@ const GLOBAL_PREFERENCES_KEY = 'metadata_fetch_preferences';
@Injectable()
export class MetadataPreferencesService {
private readonly logger = new Logger(MetadataPreferencesService.name);
constructor(
@Inject(DB) private readonly db: Db,
private readonly resolver: MetadataPreferenceResolver,
) {}
async getGlobal(): Promise<MetadataFetchPreferences> {
const startedAt = Date.now();
const row = await this.db.query.appSettings.findFirst({
where: eq(schema.appSettings.key, GLOBAL_PREFERENCES_KEY),
});
@@ -26,7 +29,14 @@ export class MetadataPreferencesService {
try {
const parsed = JSON.parse(row.value) as MetadataFetchPreferences;
return this.resolver.resolve(parsed, null);
} catch {
} catch (error) {
const durationMs = Date.now() - startedAt;
const errorClass = error instanceof Error ? error.name : 'UnknownError';
const rawMessage = error instanceof Error ? error.message : 'unknown error';
const errorMessage = rawMessage.replace(/"/g, '\\"');
this.logger.warn(
`[metadata_preferences.global_parse] [fail] key=${GLOBAL_PREFERENCES_KEY} durationMs=${durationMs} errorClass=${errorClass} error="${errorMessage}" - failed to parse persisted global preferences`,
);
return this.resolver.getDefaultPreferences();
}
}
@@ -20,6 +20,6 @@ export class ProviderConfigController {
@Put()
@HttpCode(HttpStatus.OK)
updateConfig(@Body() dto: UpdateProviderConfigDto) {
return this.service.updateConfig(dto as Parameters<typeof this.service.updateConfig>[0]);
return this.service.updateConfig(dto);
}
}
@@ -1,12 +1,28 @@
import { Logger } from '@nestjs/common';
import { MetadataProviderKey } from '@projectx/types';
import { ProviderConfigService } from './provider-config.service';
function createDb() {
const insertChain = {
function createInsertChain() {
return {
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
};
}
function createDb() {
const insertChain = createInsertChain();
const txInsertChain = createInsertChain();
const tx = {
execute: vi.fn().mockResolvedValue(undefined),
query: {
appSettings: {
findFirst: vi.fn(),
},
},
insert: vi.fn().mockReturnValue(txInsertChain),
};
return {
query: {
@@ -15,17 +31,26 @@ function createDb() {
},
},
insert: vi.fn().mockReturnValue(insertChain),
transaction: vi.fn((cb) => Promise.resolve(cb(tx))),
__insertChain: insertChain,
__tx: tx,
__txInsertChain: txInsertChain,
};
}
describe('ProviderConfigService', () => {
let db: ReturnType<typeof createDb>;
let service: ProviderConfigService;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
db = createDb();
service = new ProviderConfigService(db as never);
warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it('returns defaults when no stored config exists', async () => {
@@ -114,10 +139,25 @@ describe('ProviderConfigService', () => {
expect(config.google.apiKey).toBe('');
expect(config.hardcover.enabled).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[metadata_provider_config.parse] [fail] key=metadata_provider_config source=get durationMs='),
);
});
it('escapes quotes in parse errors before logging', async () => {
db.query.appSettings.findFirst.mockResolvedValue({ value: '{"google":{"enabled":true}}' });
const parseSpy = vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
throw new Error('bad "json" value');
});
await service.getConfig();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('error="bad \\"json\\" value"'));
parseSpy.mockRestore();
});
it('updates config via deep merge and persists the complete object', async () => {
db.query.appSettings.findFirst.mockResolvedValue({
db.__tx.query.appSettings.findFirst.mockResolvedValue({
value: JSON.stringify({
google: { enabled: true, apiKey: 'old' },
amazon: { enabled: true, domain: 'amazon.com', cookie: '' },
@@ -135,8 +175,10 @@ describe('ProviderConfigService', () => {
expect(updated.amazon).toEqual({ enabled: true, domain: 'amazon.com', cookie: 'session-cookie' });
expect(updated.hardcover).toEqual({ enabled: true, apiKey: 'h-key' });
expect(updated.itunes).toEqual({ enabled: true, coverResolution: 'standard' });
expect(db.insert).toHaveBeenCalledTimes(1);
expect(db.__insertChain.values).toHaveBeenCalledWith(
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(db.__tx.execute).toHaveBeenCalledTimes(1);
expect(db.__tx.insert).toHaveBeenCalledTimes(1);
expect(db.__txInsertChain.values).toHaveBeenCalledWith(
expect.objectContaining({
key: 'metadata_provider_config',
value: JSON.stringify(updated),
@@ -144,15 +186,28 @@ describe('ProviderConfigService', () => {
);
});
it('ignores unsupported properties during update merge', async () => {
db.query.appSettings.findFirst.mockResolvedValue(undefined);
it('acquires advisory lock before reading config inside update transaction', async () => {
db.__tx.query.appSettings.findFirst.mockResolvedValue(undefined);
const updated = await service.updateConfig({
audible: { enabled: true, domain: 'audible.com', cookie: 'legacy-cookie' } as never,
} as never);
google: { enabled: false },
});
expect(updated.audible).toEqual({ enabled: true, domain: 'audible.com' });
expect((updated.audible as Record<string, unknown>).cookie).toBeUndefined();
expect(updated.google.enabled).toBe(false);
expect(db.__tx.execute.mock.invocationCallOrder[0]).toBeLessThan(db.__tx.query.appSettings.findFirst.mock.invocationCallOrder[0]);
});
it('falls back to defaults and logs parse failures during update', async () => {
db.__tx.query.appSettings.findFirst.mockResolvedValue({ value: '{bad-json' });
const updated = await service.updateConfig({
audible: { enabled: true },
});
expect(updated.audible.enabled).toBe(true);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[metadata_provider_config.parse] [fail] key=metadata_provider_config source=update durationMs='),
);
});
it('builds provider statuses including provider-specific configuration hints', async () => {
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { MetadataProviderKey, ProviderConfigurations, ProviderStatus } from '@projectx/types';
@@ -7,6 +7,9 @@ import { DB } from '../../db';
import * as schema from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type ProviderConfigPatch = {
[K in keyof ProviderConfigurations]?: Partial<ProviderConfigurations[K]>;
};
const PROVIDER_CONFIG_KEY = 'metadata_provider_config';
@@ -106,6 +109,8 @@ const PROVIDER_LABELS: Record<MetadataProviderKey, string> = {
@Injectable()
export class ProviderConfigService {
private readonly logger = new Logger(ProviderConfigService.name);
constructor(@Inject(DB) private readonly db: Db) {}
private createDefaultConfig(): ProviderConfigurations {
@@ -122,49 +127,69 @@ export class ProviderConfigService {
};
}
private mergeConfig(base: ProviderConfigurations, value: unknown): ProviderConfigurations {
const next = asObject(value);
return {
google: mergeGoogleConfig(base.google, next.google),
amazon: mergeAmazonConfig(base.amazon, next.amazon),
goodreads: mergeSimpleConfig(base.goodreads, next.goodreads),
hardcover: mergeHardcoverConfig(base.hardcover, next.hardcover),
openLibrary: mergeSimpleConfig(base.openLibrary, next.openLibrary),
itunes: mergeITunesConfig(base.itunes, next.itunes),
audible: mergeAudibleConfig(base.audible, next.audible),
audnexus: mergeSimpleConfig(base.audnexus, next.audnexus),
comicvine: mergeComicVineConfig(base.comicvine, next.comicvine),
};
}
private parsePersistedConfig(
rawValue: string,
fallback: ProviderConfigurations,
source: 'get' | 'update',
startedAt: number,
): ProviderConfigurations {
try {
return this.mergeConfig(fallback, JSON.parse(rawValue));
} catch (error) {
const durationMs = Date.now() - startedAt;
const errorClass = error instanceof Error ? error.name : 'UnknownError';
const rawMessage = error instanceof Error ? error.message : 'unknown error';
const errorMessage = rawMessage.replace(/"/g, '\\"');
this.logger.warn(
`[metadata_provider_config.parse] [fail] key=${PROVIDER_CONFIG_KEY} source=${source} durationMs=${durationMs} errorClass=${errorClass} error="${errorMessage}" - failed to parse persisted provider config`,
);
return fallback;
}
}
async getConfig(): Promise<ProviderConfigurations> {
const startedAt = Date.now();
const defaults = this.createDefaultConfig();
const row = await this.db.query.appSettings.findFirst({
where: eq(schema.appSettings.key, PROVIDER_CONFIG_KEY),
});
if (!row) return defaults;
try {
const stored = asObject(JSON.parse(row.value));
return {
google: mergeGoogleConfig(defaults.google, stored.google),
amazon: mergeAmazonConfig(defaults.amazon, stored.amazon),
goodreads: mergeSimpleConfig(defaults.goodreads, stored.goodreads),
hardcover: mergeHardcoverConfig(defaults.hardcover, stored.hardcover),
openLibrary: mergeSimpleConfig(defaults.openLibrary, stored.openLibrary),
itunes: mergeITunesConfig(defaults.itunes, stored.itunes),
audible: mergeAudibleConfig(defaults.audible, stored.audible),
audnexus: mergeSimpleConfig(defaults.audnexus, stored.audnexus),
comicvine: mergeComicVineConfig(defaults.comicvine, stored.comicvine),
};
} catch {
return defaults;
}
return this.parsePersistedConfig(row.value, defaults, 'get', startedAt);
}
async updateConfig(patch: Partial<ProviderConfigurations>): Promise<ProviderConfigurations> {
const current = await this.getConfig();
const next: ProviderConfigurations = {
google: mergeGoogleConfig(current.google, patch.google),
amazon: mergeAmazonConfig(current.amazon, patch.amazon),
goodreads: mergeSimpleConfig(current.goodreads, patch.goodreads),
hardcover: mergeHardcoverConfig(current.hardcover, patch.hardcover),
openLibrary: mergeSimpleConfig(current.openLibrary, patch.openLibrary),
itunes: mergeITunesConfig(current.itunes, patch.itunes),
audible: mergeAudibleConfig(current.audible, patch.audible),
audnexus: mergeSimpleConfig(current.audnexus, patch.audnexus),
comicvine: mergeComicVineConfig(current.comicvine, patch.comicvine),
};
const value = JSON.stringify(next);
await this.db
.insert(schema.appSettings)
.values({ key: PROVIDER_CONFIG_KEY, value })
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
return next;
async updateConfig(patch: ProviderConfigPatch): Promise<ProviderConfigurations> {
const startedAt = Date.now();
return this.db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${PROVIDER_CONFIG_KEY})::bigint)`);
const defaults = this.createDefaultConfig();
const row = await tx.query.appSettings.findFirst({
where: eq(schema.appSettings.key, PROVIDER_CONFIG_KEY),
});
const current = row ? this.parsePersistedConfig(row.value, defaults, 'update', startedAt) : defaults;
const next = this.mergeConfig(current, patch);
const value = JSON.stringify(next);
await tx
.insert(schema.appSettings)
.values({ key: PROVIDER_CONFIG_KEY, value })
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
return next;
});
}
async getProviderStatuses(config?: ProviderConfigurations): Promise<ProviderStatus[]> {
@@ -0,0 +1,105 @@
import { MetadataRecalculationTrigger, type MetadataRecalculationStatus } from './metadata-score.service';
import { MetadataScoreController } from './metadata-score.controller';
function runningStatus(trigger: MetadataRecalculationTrigger): MetadataRecalculationStatus {
return {
state: 'running',
trigger,
startedAt: new Date('2026-01-01T00:00:00.000Z'),
endedAt: null,
processed: 0,
succeeded: 0,
failed: 0,
error: null,
};
}
describe('MetadataScoreController', () => {
const service = {
getWeights: vi.fn(),
updateWeights: vi.fn(),
requestRecalculation: vi.fn(),
getRecalculationStatus: vi.fn(),
};
const controller = new MetadataScoreController(service as never);
beforeEach(() => {
vi.clearAllMocks();
});
it('returns score weights', async () => {
service.getWeights.mockResolvedValue({ title: 10 });
await expect(controller.getWeights()).resolves.toEqual({ title: 10 });
});
it('updates score weights with DTO payload', async () => {
const dto = {
title: 10,
subtitle: 0,
description: 8,
coverSource: 10,
genres: 6,
isbn13: 7,
publisher: 4,
publishedYear: 4,
language: 4,
isbn10: 2,
pageCount: 2,
rating: 1,
seriesName: 0,
seriesIndex: 0,
tags: 2,
authors: 10,
googleBooksId: 1,
goodreadsId: 1,
amazonId: 1,
hardcoverId: 1,
openLibraryId: 1,
itunesId: 1,
};
service.updateWeights.mockResolvedValue(dto);
await expect(controller.updateWeights(dto)).resolves.toEqual(dto);
expect(service.updateWeights).toHaveBeenCalledWith(dto);
});
it('accepts a new recalculation request', () => {
const status = runningStatus(MetadataRecalculationTrigger.MANUAL);
service.requestRecalculation.mockReturnValue({ started: true, status });
expect(controller.recalculate()).toEqual({
started: true,
status,
message: 'Recalculation started',
});
expect(service.requestRecalculation).toHaveBeenCalledWith(MetadataRecalculationTrigger.MANUAL);
});
it('returns already-running message when recalculation is in progress', () => {
const status = runningStatus(MetadataRecalculationTrigger.WEIGHTS_UPDATE);
service.requestRecalculation.mockReturnValue({ started: false, status });
expect(controller.recalculate()).toEqual({
started: false,
status,
message: 'Recalculation already running',
});
});
it('returns recalculation status payload', () => {
const status = {
state: 'completed',
trigger: MetadataRecalculationTrigger.MANUAL,
startedAt: new Date('2026-01-01T00:00:00.000Z'),
endedAt: new Date('2026-01-01T00:00:02.000Z'),
processed: 200,
succeeded: 199,
failed: 1,
error: null,
} satisfies MetadataRecalculationStatus;
service.getRecalculationStatus.mockReturnValue(status);
expect(controller.getRecalculationStatus()).toEqual(status);
});
});
@@ -2,20 +2,20 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Patch, Post } from '@nestj
import { Permission } from '@projectx/types';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { UpdateWeightsDto } from './dto/update-weights.dto';
import { MetadataScoreService } from './metadata-score.service';
import { MetadataRecalculationTrigger, MetadataScoreService } from './metadata-score.service';
@Controller('metadata-score')
export class MetadataScoreController {
constructor(private readonly service: MetadataScoreService) {}
@Get('weights')
async getWeights() {
getWeights() {
return this.service.getWeights();
}
@Patch('weights')
@RequirePermission(Permission.ManageAppSettings)
async updateWeights(@Body() dto: UpdateWeightsDto) {
updateWeights(@Body() dto: UpdateWeightsDto) {
return this.service.updateWeights(dto);
}
@@ -23,7 +23,17 @@ export class MetadataScoreController {
@HttpCode(HttpStatus.ACCEPTED)
@RequirePermission(Permission.ManageAppSettings)
recalculate() {
this.service.recalculateAll().catch(() => undefined);
return { message: 'Recalculation started' };
const result = this.service.requestRecalculation(MetadataRecalculationTrigger.MANUAL);
return {
started: result.started,
status: result.status,
message: result.started ? 'Recalculation started' : 'Recalculation already running',
};
}
@Get('recalculate/status')
@RequirePermission(Permission.ManageAppSettings)
getRecalculationStatus() {
return this.service.getRecalculationStatus();
}
}
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { MetadataScoreController } from './metadata-score.controller';
import { MetadataScoreRepository } from './metadata-score.repository';
import { MetadataScoreScorer } from './metadata-score.scorer';
import { MetadataScoreService } from './metadata-score.service';
@Module({
imports: [AppSettingsModule],
providers: [MetadataScoreService],
providers: [MetadataScoreService, MetadataScoreRepository, MetadataScoreScorer],
controllers: [MetadataScoreController],
exports: [MetadataScoreService],
})
@@ -0,0 +1,115 @@
import { Inject, Injectable } from '@nestjs/common';
import { asc, count, eq, gt, inArray } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookAuthors, bookGenres, bookMetadata, bookTags } from '../../db/schema';
import type { ScoreData } from './metadata-score.scorer';
type Db = NodePgDatabase<typeof schema>;
const METADATA_SCORE_BASE_SELECT = {
title: bookMetadata.title,
subtitle: bookMetadata.subtitle,
description: bookMetadata.description,
isbn10: bookMetadata.isbn10,
isbn13: bookMetadata.isbn13,
publisher: bookMetadata.publisher,
publishedYear: bookMetadata.publishedYear,
language: bookMetadata.language,
pageCount: bookMetadata.pageCount,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
rating: bookMetadata.rating,
coverSource: bookMetadata.coverSource,
googleBooksId: bookMetadata.googleBooksId,
goodreadsId: bookMetadata.goodreadsId,
amazonId: bookMetadata.amazonId,
hardcoverId: bookMetadata.hardcoverId,
openLibraryId: bookMetadata.openLibraryId,
itunesId: bookMetadata.itunesId,
} as const;
export type ScorePage = {
rows: { bookId: number; data: ScoreData }[];
nextCursor: number | null;
};
@Injectable()
export class MetadataScoreRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async loadScoreData(bookId: number): Promise<ScoreData | null> {
const [metaRow] = await this.db.select(METADATA_SCORE_BASE_SELECT).from(bookMetadata).where(eq(bookMetadata.bookId, bookId)).limit(1);
if (!metaRow) return null;
const [[{ authorCount }], [{ genreCount }], [{ tagCount }]] = await Promise.all([
this.db.select({ authorCount: count() }).from(bookAuthors).where(eq(bookAuthors.bookId, bookId)),
this.db.select({ genreCount: count() }).from(bookGenres).where(eq(bookGenres.bookId, bookId)),
this.db.select({ tagCount: count() }).from(bookTags).where(eq(bookTags.bookId, bookId)),
]);
return {
...metaRow,
authorCount: Number(authorCount),
genreCount: Number(genreCount),
tagCount: Number(tagCount),
};
}
async loadScoreDataPage(afterBookId: number | null, pageSize: number): Promise<ScorePage> {
const selectShape = { bookId: bookMetadata.bookId, ...METADATA_SCORE_BASE_SELECT };
const metaRows =
afterBookId === null
? await this.db.select(selectShape).from(bookMetadata).orderBy(asc(bookMetadata.bookId)).limit(pageSize)
: await this.db
.select(selectShape)
.from(bookMetadata)
.where(gt(bookMetadata.bookId, afterBookId))
.orderBy(asc(bookMetadata.bookId))
.limit(pageSize);
if (metaRows.length === 0) {
return { rows: [], nextCursor: null };
}
const bookIds = metaRows.map((row) => row.bookId);
const [authorCounts, genreCounts, tagCounts] = await Promise.all([
this.db
.select({ bookId: bookAuthors.bookId, cnt: count() })
.from(bookAuthors)
.where(inArray(bookAuthors.bookId, bookIds))
.groupBy(bookAuthors.bookId),
this.db
.select({ bookId: bookGenres.bookId, cnt: count() })
.from(bookGenres)
.where(inArray(bookGenres.bookId, bookIds))
.groupBy(bookGenres.bookId),
this.db.select({ bookId: bookTags.bookId, cnt: count() }).from(bookTags).where(inArray(bookTags.bookId, bookIds)).groupBy(bookTags.bookId),
]);
const authorMap = new Map(authorCounts.map((row) => [row.bookId, Number(row.cnt)]));
const genreMap = new Map(genreCounts.map((row) => [row.bookId, Number(row.cnt)]));
const tagMap = new Map(tagCounts.map((row) => [row.bookId, Number(row.cnt)]));
const rows = metaRows.map((row) => ({
bookId: row.bookId,
data: {
...row,
authorCount: authorMap.get(row.bookId) ?? 0,
genreCount: genreMap.get(row.bookId) ?? 0,
tagCount: tagMap.get(row.bookId) ?? 0,
},
}));
return {
rows,
nextCursor: rows[rows.length - 1]?.bookId ?? null,
};
}
async updateMetadataScore(bookId: number, score: number): Promise<void> {
await this.db.update(bookMetadata).set({ metadataScore: score }).where(eq(bookMetadata.bookId, bookId));
}
}
@@ -0,0 +1,117 @@
import { DEFAULT_METADATA_SCORE_WEIGHTS, type MetadataScoreWeights } from '@projectx/types';
import { MetadataScoreScorer, type ScoreData } from './metadata-score.scorer';
function makeData(overrides: Partial<ScoreData> = {}): ScoreData {
return {
title: null,
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
rating: null,
coverSource: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
authorCount: 0,
genreCount: 0,
tagCount: 0,
...overrides,
};
}
describe('MetadataScoreScorer', () => {
const scorer = new MetadataScoreScorer();
it('returns 0 when total weight is zero', () => {
const zeroWeights = Object.fromEntries(Object.keys(DEFAULT_METADATA_SCORE_WEIGHTS).map((key) => [key, 0])) as MetadataScoreWeights;
expect(scorer.compute(makeData({ title: 'Dune' }), zeroWeights)).toBe(0);
});
it('treats whitespace-only strings as missing values', () => {
const data = makeData({ title: ' ' });
expect(scorer.compute(data, DEFAULT_METADATA_SCORE_WEIGHTS)).toBe(0);
});
it('awards points for counts and positive numeric values only', () => {
const data = makeData({
authorCount: 1,
genreCount: 2,
tagCount: 3,
publishedYear: 2020,
pageCount: 450,
rating: 4.5,
seriesIndex: 1,
});
const weights: MetadataScoreWeights = {
...DEFAULT_METADATA_SCORE_WEIGHTS,
authors: 10,
genres: 10,
tags: 10,
publishedYear: 10,
pageCount: 10,
rating: 10,
seriesIndex: 10,
title: 0,
subtitle: 0,
description: 0,
coverSource: 0,
isbn13: 0,
publisher: 0,
language: 0,
isbn10: 0,
seriesName: 0,
googleBooksId: 0,
goodreadsId: 0,
amazonId: 0,
hardcoverId: 0,
openLibraryId: 0,
itunesId: 0,
};
expect(scorer.compute(data, weights)).toBe(100);
expect(scorer.compute(makeData({ publishedYear: 0, pageCount: -1, rating: 0 }), weights)).toBe(0);
});
it('ignores non-finite and non-positive weights while computing total', () => {
const weights: MetadataScoreWeights = {
...DEFAULT_METADATA_SCORE_WEIGHTS,
title: Number.NaN,
authors: -5,
coverSource: 10,
description: 0,
subtitle: 0,
genres: 0,
isbn13: 0,
publisher: 0,
publishedYear: 0,
language: 0,
isbn10: 0,
pageCount: 0,
rating: 0,
seriesName: 0,
seriesIndex: 0,
tags: 0,
googleBooksId: 0,
goodreadsId: 0,
amazonId: 0,
hardcoverId: 0,
openLibraryId: 0,
itunesId: 0,
};
expect(scorer.compute(makeData({ title: 'Dune', coverSource: 'extracted', authorCount: 10 }), weights)).toBe(100);
});
});
@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import type { MetadataScoreField, MetadataScoreWeights } from '@projectx/types';
export type ScoreData = {
title: string | null;
subtitle: string | null;
description: string | null;
isbn10: string | null;
isbn13: string | null;
publisher: string | null;
publishedYear: number | null;
language: string | null;
pageCount: number | null;
seriesName: string | null;
seriesIndex: number | null;
rating: number | null;
coverSource: string | null;
googleBooksId: string | null;
goodreadsId: string | null;
amazonId: string | null;
hardcoverId: string | null;
openLibraryId: string | null;
itunesId: string | null;
authorCount: number;
genreCount: number;
tagCount: number;
};
type ScoreRuleKind = 'string' | 'positive-number' | 'count';
type ScoreRule = { kind: ScoreRuleKind; value: (data: ScoreData) => string | number | null };
const SCORE_RULES: Record<MetadataScoreField, ScoreRule> = {
title: { kind: 'string', value: (data) => data.title },
subtitle: { kind: 'string', value: (data) => data.subtitle },
description: { kind: 'string', value: (data) => data.description },
coverSource: { kind: 'string', value: (data) => data.coverSource },
genres: { kind: 'count', value: (data) => data.genreCount },
isbn13: { kind: 'string', value: (data) => data.isbn13 },
publisher: { kind: 'string', value: (data) => data.publisher },
publishedYear: { kind: 'positive-number', value: (data) => data.publishedYear },
language: { kind: 'string', value: (data) => data.language },
isbn10: { kind: 'string', value: (data) => data.isbn10 },
pageCount: { kind: 'positive-number', value: (data) => data.pageCount },
rating: { kind: 'positive-number', value: (data) => data.rating },
seriesName: { kind: 'string', value: (data) => data.seriesName },
seriesIndex: { kind: 'positive-number', value: (data) => data.seriesIndex },
tags: { kind: 'count', value: (data) => data.tagCount },
authors: { kind: 'count', value: (data) => data.authorCount },
googleBooksId: { kind: 'string', value: (data) => data.googleBooksId },
goodreadsId: { kind: 'string', value: (data) => data.goodreadsId },
amazonId: { kind: 'string', value: (data) => data.amazonId },
hardcoverId: { kind: 'string', value: (data) => data.hardcoverId },
openLibraryId: { kind: 'string', value: (data) => data.openLibraryId },
itunesId: { kind: 'string', value: (data) => data.itunesId },
};
@Injectable()
export class MetadataScoreScorer {
compute(data: ScoreData, weights: MetadataScoreWeights): number {
let earned = 0;
let total = 0;
for (const [field, rawWeight] of Object.entries(weights) as [MetadataScoreField, number][]) {
const weight = Number(rawWeight);
if (!Number.isFinite(weight) || weight <= 0) continue;
total += weight;
if (this.hasValue(SCORE_RULES[field], data)) {
earned += weight;
}
}
if (total === 0) return 0;
return Math.floor((earned / total) * 100);
}
private hasValue(rule: ScoreRule, data: ScoreData): boolean {
const raw = rule.value(data);
switch (rule.kind) {
case 'string':
return typeof raw === 'string' && raw.trim().length > 0;
case 'count':
case 'positive-number':
return typeof raw === 'number' && raw > 0;
}
}
}
@@ -0,0 +1,301 @@
import type { MetadataScoreWeights } from '@projectx/types';
import type { ScorePage } from './metadata-score.repository';
import { MetadataRecalculationTrigger, MetadataScoreService } from './metadata-score.service';
function deferred<T>() {
let resolve: (value: T) => void = () => undefined;
let reject: (reason?: unknown) => void = () => undefined;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function makeWeights(): MetadataScoreWeights {
return {
title: 10,
subtitle: 0,
description: 8,
coverSource: 10,
genres: 6,
isbn13: 7,
publisher: 4,
publishedYear: 4,
language: 4,
isbn10: 2,
pageCount: 2,
rating: 1,
seriesName: 0,
seriesIndex: 0,
tags: 2,
authors: 10,
googleBooksId: 1,
goodreadsId: 1,
amazonId: 1,
hardcoverId: 1,
openLibraryId: 1,
itunesId: 1,
};
}
function makeService() {
const repo = {
loadScoreData: vi.fn(),
loadScoreDataPage: vi.fn(),
updateMetadataScore: vi.fn(),
};
const scorer = {
compute: vi.fn(),
};
const appSettings = {
getMetadataScoreWeights: vi.fn(),
setMetadataScoreWeights: vi.fn(),
};
const service = new MetadataScoreService(repo as never, scorer as never, appSettings as never);
return { service, repo, scorer, appSettings };
}
async function waitForNonRunningStatus(service: MetadataScoreService): Promise<void> {
for (let i = 0; i < 50; i++) {
if (service.getRecalculationStatus().state !== 'running') return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error('Recalculation did not finish within timeout');
}
describe('MetadataScoreService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns the current weights from settings', async () => {
const { service, appSettings } = makeService();
const weights = makeWeights();
appSettings.getMetadataScoreWeights.mockResolvedValue(weights);
await expect(service.getWeights()).resolves.toEqual(weights);
});
it('updates score for a single book when metadata exists', async () => {
const { service, repo, scorer, appSettings } = makeService();
const weights = makeWeights();
const data = {
title: 'Dune',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
rating: null,
coverSource: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
authorCount: 1,
genreCount: 0,
tagCount: 0,
};
repo.loadScoreData.mockResolvedValue(data);
appSettings.getMetadataScoreWeights.mockResolvedValue(weights);
scorer.compute.mockReturnValue(87);
await service.calculateAndSave(12);
expect(scorer.compute).toHaveBeenCalledWith(data, weights);
expect(repo.updateMetadataScore).toHaveBeenCalledWith(12, 87);
});
it('skips score update when the metadata row is missing', async () => {
const { service, repo, scorer, appSettings } = makeService();
repo.loadScoreData.mockResolvedValue(null);
appSettings.getMetadataScoreWeights.mockResolvedValue(makeWeights());
await service.calculateAndSave(99);
expect(scorer.compute).not.toHaveBeenCalled();
expect(repo.updateMetadataScore).not.toHaveBeenCalled();
});
it('starts manual recalculation and reports completed status with success/failure counters', async () => {
const { service, repo, scorer, appSettings } = makeService();
const weights = makeWeights();
appSettings.getMetadataScoreWeights.mockResolvedValue(weights);
const page1: ScorePage = {
rows: [
{
bookId: 1,
data: {
title: 'A',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
rating: null,
coverSource: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
authorCount: 1,
genreCount: 0,
tagCount: 0,
},
},
{
bookId: 2,
data: {
title: 'B',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
rating: null,
coverSource: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
authorCount: 1,
genreCount: 0,
tagCount: 0,
},
},
],
nextCursor: 2,
};
const page2: ScorePage = {
rows: [
{
bookId: 3,
data: {
title: 'C',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
rating: null,
coverSource: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
authorCount: 1,
genreCount: 0,
tagCount: 0,
},
},
],
nextCursor: 3,
};
repo.loadScoreDataPage.mockResolvedValueOnce(page1).mockResolvedValueOnce(page2).mockResolvedValueOnce({ rows: [], nextCursor: null });
scorer.compute.mockReturnValue(80);
repo.updateMetadataScore.mockResolvedValue(undefined);
repo.updateMetadataScore.mockRejectedValueOnce(new Error('write failed'));
const started = service.requestRecalculation(MetadataRecalculationTrigger.MANUAL);
expect(started.started).toBe(true);
expect(started.status.state).toBe('running');
await waitForNonRunningStatus(service);
const status = service.getRecalculationStatus();
expect(status.state).toBe('completed');
expect(status.processed).toBe(3);
expect(status.succeeded).toBe(2);
expect(status.failed).toBe(1);
expect(status.trigger).toBe(MetadataRecalculationTrigger.MANUAL);
expect(status.error).toBeNull();
});
it('rejects duplicate start requests while recalculation is already running', async () => {
const { service, repo, appSettings } = makeService();
appSettings.getMetadataScoreWeights.mockResolvedValue(makeWeights());
const slowPage = deferred<ScorePage>();
repo.loadScoreDataPage.mockReturnValueOnce(slowPage.promise);
const first = service.requestRecalculation(MetadataRecalculationTrigger.MANUAL);
const second = service.requestRecalculation(MetadataRecalculationTrigger.MANUAL);
expect(first.started).toBe(true);
expect(second.started).toBe(false);
expect(second.status.state).toBe('running');
slowPage.resolve({ rows: [], nextCursor: null });
await waitForNonRunningStatus(service);
});
it('marks status as failed when a fatal page load error occurs', async () => {
const { service, repo, appSettings } = makeService();
appSettings.getMetadataScoreWeights.mockResolvedValue(makeWeights());
repo.loadScoreDataPage.mockRejectedValue(new Error('db unavailable'));
service.requestRecalculation(MetadataRecalculationTrigger.MANUAL);
await waitForNonRunningStatus(service);
const status = service.getRecalculationStatus();
expect(status.state).toBe('failed');
expect(status.error).toBe('db unavailable');
expect(status.trigger).toBe(MetadataRecalculationTrigger.MANUAL);
});
it('triggers a background recalculation after updating weights', async () => {
const { service, appSettings } = makeService();
const weights = makeWeights();
appSettings.setMetadataScoreWeights.mockResolvedValue(weights);
const requestSpy = vi.spyOn(service, 'requestRecalculation').mockReturnValue({
started: true,
status: {
state: 'running',
trigger: MetadataRecalculationTrigger.WEIGHTS_UPDATE,
startedAt: new Date(),
endedAt: null,
processed: 0,
succeeded: 0,
failed: 0,
error: null,
},
});
await expect(service.updateWeights(weights)).resolves.toEqual(weights);
expect(appSettings.setMetadataScoreWeights).toHaveBeenCalledWith(weights);
expect(requestSpy).toHaveBeenCalledWith(MetadataRecalculationTrigger.WEIGHTS_UPDATE);
});
});
@@ -1,46 +1,42 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { count, eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Injectable, Logger } from '@nestjs/common';
import { type MetadataScoreWeights } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookAuthors, bookGenres, bookMetadata, bookTags } from '../../db/schema';
import type { MetadataScoreWeights } from '@projectx/types';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { MetadataScoreRepository } from './metadata-score.repository';
import { MetadataScoreScorer, type ScoreData } from './metadata-score.scorer';
type Db = NodePgDatabase<typeof schema>;
const RECALCULATION_PAGE_SIZE = 200;
const RECALCULATION_WRITE_CONCURRENCY = 25;
const RECALCULATION_EVENT = 'metadata_score.recalculate_all';
const RECALCULATION_BOOK_EVENT = 'metadata_score.recalculate_book';
type ScoreData = {
title: string | null;
subtitle: string | null;
description: string | null;
isbn10: string | null;
isbn13: string | null;
publisher: string | null;
publishedYear: number | null;
language: string | null;
pageCount: number | null;
seriesName: string | null;
seriesIndex: number | null;
rating: number | null;
coverSource: string | null;
googleBooksId: string | null;
goodreadsId: string | null;
amazonId: string | null;
hardcoverId: string | null;
openLibraryId: string | null;
itunesId: string | null;
authorCount: number;
genreCount: number;
tagCount: number;
export enum MetadataRecalculationTrigger {
MANUAL = 'manual',
WEIGHTS_UPDATE = 'weights_update',
}
export type MetadataRecalculationState = 'idle' | 'running' | 'completed' | 'failed';
export type MetadataRecalculationStatus = {
state: MetadataRecalculationState;
trigger: MetadataRecalculationTrigger | null;
startedAt: Date | null;
endedAt: Date | null;
processed: number;
succeeded: number;
failed: number;
error: string | null;
};
@Injectable()
export class MetadataScoreService {
private readonly logger = new Logger(MetadataScoreService.name);
private runningPromise: Promise<void> | null = null;
private status: MetadataRecalculationStatus = this.makeInitialStatus();
constructor(
@Inject(DB) private readonly db: Db,
private readonly repo: MetadataScoreRepository,
private readonly scorer: MetadataScoreScorer,
private readonly appSettings: AppSettingsService,
) {}
@@ -48,185 +44,132 @@ export class MetadataScoreService {
return this.appSettings.getMetadataScoreWeights();
}
getRecalculationStatus(): MetadataRecalculationStatus {
return { ...this.status };
}
async updateWeights(weights: MetadataScoreWeights): Promise<MetadataScoreWeights> {
await this.appSettings.setMetadataScoreWeights(weights);
this.recalculateAll().catch((err: Error) => this.logger.warn(`Recalculate-all after weight update failed: ${err.message}`));
this.requestRecalculation(MetadataRecalculationTrigger.WEIGHTS_UPDATE);
return weights;
}
async calculateAndSave(bookId: number): Promise<void> {
const [data, weights] = await Promise.all([this.loadScoreData(bookId), this.appSettings.getMetadataScoreWeights()]);
if (!data) return;
const score = this.compute(data, weights);
await this.db.update(bookMetadata).set({ metadataScore: score }).where(eq(bookMetadata.bookId, bookId));
}
async recalculateAll(): Promise<{ processed: number }> {
const weights = await this.appSettings.getMetadataScoreWeights();
const allRows = await this.loadAllScoreData();
const BATCH = 200;
let processed = 0;
for (let i = 0; i < allRows.length; i += BATCH) {
const batch = allRows.slice(i, i + BATCH);
await Promise.all(
batch.map(({ bookId, data }) => {
const score = this.compute(data, weights);
return this.db
.update(bookMetadata)
.set({ metadataScore: score })
.where(eq(bookMetadata.bookId, bookId))
.catch((err: Error) => this.logger.warn(`Score recalculation failed for book ${bookId}: ${err.message}`));
}),
);
processed += batch.length;
requestRecalculation(trigger: MetadataRecalculationTrigger): { started: boolean; status: MetadataRecalculationStatus } {
if (this.runningPromise) {
return { started: false, status: this.getRecalculationStatus() };
}
this.logger.log(`Metadata score recalculation complete: ${processed} books processed`);
return { processed };
this.runningPromise = this.runRecalculation(trigger).finally(() => {
this.runningPromise = null;
});
return { started: true, status: this.getRecalculationStatus() };
}
private async loadAllScoreData(): Promise<{ bookId: number; data: ScoreData }[]> {
const metaRows = await this.db
.select({
bookId: bookMetadata.bookId,
title: bookMetadata.title,
subtitle: bookMetadata.subtitle,
description: bookMetadata.description,
isbn10: bookMetadata.isbn10,
isbn13: bookMetadata.isbn13,
publisher: bookMetadata.publisher,
publishedYear: bookMetadata.publishedYear,
language: bookMetadata.language,
pageCount: bookMetadata.pageCount,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
rating: bookMetadata.rating,
coverSource: bookMetadata.coverSource,
googleBooksId: bookMetadata.googleBooksId,
goodreadsId: bookMetadata.goodreadsId,
amazonId: bookMetadata.amazonId,
hardcoverId: bookMetadata.hardcoverId,
openLibraryId: bookMetadata.openLibraryId,
itunesId: bookMetadata.itunesId,
})
.from(bookMetadata);
if (metaRows.length === 0) return [];
const bookIds = metaRows.map((r) => r.bookId);
const [authorCounts, genreCounts, tagCounts] = await Promise.all([
this.db
.select({ bookId: bookAuthors.bookId, cnt: count() })
.from(bookAuthors)
.where(inArray(bookAuthors.bookId, bookIds))
.groupBy(bookAuthors.bookId),
this.db
.select({ bookId: bookGenres.bookId, cnt: count() })
.from(bookGenres)
.where(inArray(bookGenres.bookId, bookIds))
.groupBy(bookGenres.bookId),
this.db.select({ bookId: bookTags.bookId, cnt: count() }).from(bookTags).where(inArray(bookTags.bookId, bookIds)).groupBy(bookTags.bookId),
]);
const authorMap = new Map(authorCounts.map((r) => [r.bookId, Number(r.cnt)]));
const genreMap = new Map(genreCounts.map((r) => [r.bookId, Number(r.cnt)]));
const tagMap = new Map(tagCounts.map((r) => [r.bookId, Number(r.cnt)]));
return metaRows.map((row) => ({
bookId: row.bookId,
data: {
...row,
authorCount: authorMap.get(row.bookId) ?? 0,
genreCount: genreMap.get(row.bookId) ?? 0,
tagCount: tagMap.get(row.bookId) ?? 0,
},
}));
async calculateAndSave(bookId: number): Promise<void> {
const [data, weights] = await Promise.all([this.repo.loadScoreData(bookId), this.appSettings.getMetadataScoreWeights()]);
if (!data) return;
const score = this.scorer.compute(data, weights);
await this.repo.updateMetadataScore(bookId, score);
}
private async loadScoreData(bookId: number): Promise<ScoreData | null> {
const [metaRow] = await this.db
.select({
title: bookMetadata.title,
subtitle: bookMetadata.subtitle,
description: bookMetadata.description,
isbn10: bookMetadata.isbn10,
isbn13: bookMetadata.isbn13,
publisher: bookMetadata.publisher,
publishedYear: bookMetadata.publishedYear,
language: bookMetadata.language,
pageCount: bookMetadata.pageCount,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
rating: bookMetadata.rating,
coverSource: bookMetadata.coverSource,
googleBooksId: bookMetadata.googleBooksId,
goodreadsId: bookMetadata.goodreadsId,
amazonId: bookMetadata.amazonId,
hardcoverId: bookMetadata.hardcoverId,
openLibraryId: bookMetadata.openLibraryId,
itunesId: bookMetadata.itunesId,
})
.from(bookMetadata)
.where(eq(bookMetadata.bookId, bookId))
.limit(1);
private async runRecalculation(trigger: MetadataRecalculationTrigger): Promise<void> {
const startedAt = Date.now();
this.status = {
state: 'running',
trigger,
startedAt: new Date(startedAt),
endedAt: null,
processed: 0,
succeeded: 0,
failed: 0,
error: null,
};
this.logger.log(`[${RECALCULATION_EVENT}] [start] trigger=${trigger} - metadata score recalculation started`);
if (!metaRow) return null;
try {
const weights = await this.appSettings.getMetadataScoreWeights();
let cursor: number | null = null;
while (true) {
const page = await this.repo.loadScoreDataPage(cursor, RECALCULATION_PAGE_SIZE);
if (page.rows.length === 0) break;
const [[{ authorCount }], [{ genreCount }], [{ tagCount }]] = await Promise.all([
this.db.select({ authorCount: count() }).from(bookAuthors).where(eq(bookAuthors.bookId, bookId)),
this.db.select({ genreCount: count() }).from(bookGenres).where(eq(bookGenres.bookId, bookId)),
this.db.select({ tagCount: count() }).from(bookTags).where(eq(bookTags.bookId, bookId)),
]);
for (let i = 0; i < page.rows.length; i += RECALCULATION_WRITE_CONCURRENCY) {
const chunk = page.rows.slice(i, i + RECALCULATION_WRITE_CONCURRENCY);
const outcomes = await Promise.all(chunk.map((row) => this.recalculateBook(row.bookId, row.data, weights)));
this.status.processed += outcomes.length;
this.status.succeeded += outcomes.filter((ok) => ok).length;
this.status.failed += outcomes.filter((ok) => !ok).length;
}
cursor = page.nextCursor;
}
this.status = {
...this.status,
state: 'completed',
endedAt: new Date(),
};
this.logger.log(
`[${RECALCULATION_EVENT}] [end] trigger=${trigger} durationMs=${Date.now() - startedAt} processed=${this.status.processed} succeeded=${this.status.succeeded} failed=${this.status.failed} - metadata score recalculation completed`,
);
} catch (err) {
const { errorClass, errorMessage } = this.parseError(err);
this.status = {
...this.status,
state: 'failed',
endedAt: new Date(),
error: errorMessage,
};
this.logger.warn(
`[${RECALCULATION_EVENT}] [fail] trigger=${trigger} durationMs=${Date.now() - startedAt} processed=${this.status.processed} succeeded=${this.status.succeeded} failed=${this.status.failed} errorClass=${errorClass} error="${errorMessage}" - metadata score recalculation failed`,
);
}
}
private async recalculateBook(bookId: number, data: ScoreData, weights: MetadataScoreWeights): Promise<boolean> {
const startedAt = Date.now();
try {
const score = this.scorer.compute(data, weights);
await this.repo.updateMetadataScore(bookId, score);
return true;
} catch (err) {
const { errorClass, errorMessage } = this.parseError(err);
this.logger.warn(
`[${RECALCULATION_BOOK_EVENT}] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata score update failed`,
);
return false;
}
}
private parseError(err: unknown): { errorClass: string; errorMessage: string } {
if (err instanceof Error) {
return {
errorClass: err.constructor.name,
errorMessage: this.sanitizeErrorMessage(err.message),
};
}
return {
...metaRow,
authorCount: Number(authorCount),
genreCount: Number(genreCount),
tagCount: Number(tagCount),
errorClass: 'UnknownError',
errorMessage: this.sanitizeErrorMessage(String(err)),
};
}
private compute(data: ScoreData, weights: MetadataScoreWeights): number {
const earned =
this.scoreString(data.title, weights.title) +
this.scoreString(data.subtitle, weights.subtitle) +
this.scoreString(data.description, weights.description) +
this.scoreString(data.coverSource, weights.coverSource) +
this.scoreCount(data.authorCount, weights.authors) +
this.scoreCount(data.genreCount, weights.genres) +
this.scoreCount(data.tagCount, weights.tags) +
this.scoreString(data.isbn13, weights.isbn13) +
this.scoreString(data.isbn10, weights.isbn10) +
this.scoreString(data.publisher, weights.publisher) +
this.scorePositiveNumber(data.publishedYear, weights.publishedYear) +
this.scoreString(data.language, weights.language) +
this.scorePositiveNumber(data.pageCount, weights.pageCount) +
this.scorePositiveNumber(data.rating, weights.rating) +
this.scoreString(data.seriesName, weights.seriesName) +
this.scorePositiveNumber(data.seriesIndex, weights.seriesIndex) +
this.scoreString(data.googleBooksId, weights.googleBooksId) +
this.scoreString(data.goodreadsId, weights.goodreadsId) +
this.scoreString(data.amazonId, weights.amazonId) +
this.scoreString(data.hardcoverId, weights.hardcoverId) +
this.scoreString(data.openLibraryId, weights.openLibraryId) +
this.scoreString(data.itunesId, weights.itunesId);
const total = (Object.values(weights) as number[]).reduce((sum, w) => sum + w, 0);
if (total === 0) return 0;
return Math.floor((earned / total) * 100);
private sanitizeErrorMessage(message: string): string {
return message.replace(/[\r\n"]/g, ' ').slice(0, 200);
}
private scoreString(value: string | null | undefined, weight: number): number {
return value != null && value.trim().length > 0 ? weight : 0;
}
private scoreCount(count: number, weight: number): number {
return count > 0 ? weight : 0;
}
private scorePositiveNumber(value: number | null | undefined, weight: number): number {
return value != null && value > 0 ? weight : 0;
private makeInitialStatus(): MetadataRecalculationStatus {
return {
state: 'idle',
trigger: null,
startedAt: null,
endedAt: null,
processed: 0,
succeeded: 0,
failed: 0,
error: null,
};
}
}
@@ -0,0 +1,105 @@
import type { Mocked } from 'vitest';
import type { RequestUser } from '../../common/types/request-user';
import { ReaderPreferencesController } from './reader-preferences.controller';
import { ReaderPreferencesService } from './reader-preferences.service';
function makeUser(overrides?: Partial<RequestUser>): RequestUser {
return {
id: 7,
username: 'reader',
name: 'Reader',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
...overrides,
};
}
describe('ReaderPreferencesController', () => {
let service: Mocked<ReaderPreferencesService>;
let controller: ReaderPreferencesController;
beforeEach(() => {
service = {
getPreference: vi.fn(),
upsertPreference: vi.fn(),
deletePreference: vi.fn(),
getAllDefaults: vi.fn(),
upsertDefault: vi.fn(),
deleteDefault: vi.fn(),
} as unknown as Mocked<ReaderPreferencesService>;
controller = new ReaderPreferencesController(service);
});
it('returns null settings with isCustomized=false when no per-book preference exists', async () => {
service.getPreference.mockResolvedValueOnce(null as never);
const result = await controller.getPreference(9, makeUser({ id: 11 }));
expect(service.getPreference).toHaveBeenCalledWith(expect.objectContaining({ id: 11 }), 9);
expect(result).toEqual({ settings: null, isCustomized: false });
});
it('returns settings with isCustomized=true when per-book preference exists', async () => {
const settings = { zoomMode: 'fit-width' };
service.getPreference.mockResolvedValueOnce({ settings } as never);
const result = await controller.getPreference(9, makeUser());
expect(result).toEqual({ settings, isCustomized: true });
});
it('delegates per-book upsert to service', async () => {
const user = makeUser({ id: 15 });
const dto = { settings: { customScale: 1.2 } };
await controller.upsertPreference(5, dto, user);
expect(service.upsertPreference).toHaveBeenCalledWith(user, 5, dto.settings);
});
it('delegates per-book delete to service', async () => {
const user = makeUser({ id: 15 });
await controller.deletePreference(5, user);
expect(service.deletePreference).toHaveBeenCalledWith(user, 5);
});
it('returns defaults as a format-group keyed map', async () => {
service.getAllDefaults.mockResolvedValueOnce([
{ formatGroup: 'pdf', settings: { zoomMode: 'fit-page' } },
{ formatGroup: 'audio', settings: { volume: 0.8 } },
] as never);
const result = await controller.getAllDefaults(makeUser({ id: 3 }));
expect(service.getAllDefaults).toHaveBeenCalledWith(3);
expect(result).toEqual({
pdf: { zoomMode: 'fit-page' },
audio: { volume: 0.8 },
});
});
it('delegates default upsert to service', async () => {
const dto = { settings: { scrollMode: 'page', spread: 'none', zoomMode: 'fit-page', customScale: 1, rotation: 0 } };
await controller.upsertDefault('pdf', dto, makeUser({ id: 55 }));
expect(service.upsertDefault).toHaveBeenCalledWith(55, 'pdf', dto.settings);
});
it('delegates default delete to service', async () => {
await controller.deleteDefault('pdf', makeUser({ id: 55 }));
expect(service.deleteDefault).toHaveBeenCalledWith(55, 'pdf');
});
});
@@ -11,20 +11,20 @@ export class ReaderPreferencesController {
@Get('preferences/:bookFileId')
async getPreference(@Param('bookFileId', ParseIntPipe) bookFileId: number, @CurrentUser() user: RequestUser) {
const pref = await this.readerPreferencesService.getPreference(user.id, bookFileId);
const pref = await this.readerPreferencesService.getPreference(user, bookFileId);
return { settings: pref?.settings ?? null, isCustomized: !!pref };
}
@Put('preferences/:bookFileId')
@HttpCode(204)
async upsertPreference(@Param('bookFileId', ParseIntPipe) bookFileId: number, @Body() dto: UpsertPreferenceDto, @CurrentUser() user: RequestUser) {
await this.readerPreferencesService.upsertPreference(user.id, bookFileId, dto.settings);
await this.readerPreferencesService.upsertPreference(user, bookFileId, dto.settings);
}
@Delete('preferences/:bookFileId')
@HttpCode(204)
async deletePreference(@Param('bookFileId', ParseIntPipe) bookFileId: number, @CurrentUser() user: RequestUser) {
await this.readerPreferencesService.deletePreference(user.id, bookFileId);
await this.readerPreferencesService.deletePreference(user, bookFileId);
}
@Get('defaults')
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { ReaderPreferencesController } from './reader-preferences.controller';
import { ReaderPreferencesRepository } from './reader-preferences.repository';
import { ReaderPreferencesService } from './reader-preferences.service';
@Module({
imports: [BookModule],
controllers: [ReaderPreferencesController],
providers: [ReaderPreferencesRepository, ReaderPreferencesService],
})
@@ -18,12 +18,13 @@ export class ReaderPreferencesRepository {
}
async upsertPreference(userId: number, bookFileId: number, settings: Record<string, unknown>) {
const now = new Date();
await this.db
.insert(schema.readerPreferences)
.values({ userId, bookFileId, settings, updatedAt: new Date() })
.values({ userId, bookFileId, settings, updatedAt: now })
.onConflictDoUpdate({
target: [schema.readerPreferences.userId, schema.readerPreferences.bookFileId],
set: { settings, updatedAt: new Date() },
set: { settings, updatedAt: now },
});
}
@@ -40,12 +41,13 @@ export class ReaderPreferencesRepository {
}
async upsertDefault(userId: number, formatGroup: string, settings: Record<string, unknown>) {
const now = new Date();
await this.db
.insert(schema.readerDefaultPreferences)
.values({ userId, formatGroup, settings, updatedAt: new Date() })
.values({ userId, formatGroup, settings, updatedAt: now })
.onConflictDoUpdate({
target: [schema.readerDefaultPreferences.userId, schema.readerDefaultPreferences.formatGroup],
set: { settings, updatedAt: new Date() },
set: { settings, updatedAt: now },
});
}
@@ -0,0 +1,153 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RequestUser } from '../../common/types/request-user';
import { BookService } from '../book/book.service';
import { ReaderPreferencesRepository } from './reader-preferences.repository';
import { ReaderPreferencesService } from './reader-preferences.service';
function makeUser(overrides?: Partial<RequestUser>): RequestUser {
return {
id: 7,
username: 'reader',
name: 'Reader',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
...overrides,
};
}
const validPdfDefaults = {
scrollMode: 'page',
spread: 'none',
zoomMode: 'fit-page',
customScale: 1,
rotation: 0,
} as const;
const mockRepo = {
findPreference: vi.fn<(...args: [number, number]) => Promise<{ settings: Record<string, unknown> } | null>>(),
upsertPreference: vi.fn<(...args: [number, number, Record<string, unknown>]) => Promise<void>>(),
deletePreference: vi.fn<(...args: [number, number]) => Promise<void>>(),
findAllDefaults: vi.fn<(...args: [number]) => Promise<Array<{ formatGroup: string; settings: Record<string, unknown> }>>>(),
upsertDefault: vi.fn<(...args: [number, string, Record<string, unknown>]) => Promise<void>>(),
deleteDefault: vi.fn<(...args: [number, string]) => Promise<void>>(),
};
const mockBookService = {
verifyFileAccess: vi.fn<(...args: [number, RequestUser]) => Promise<{ format: string | null }>>(),
};
describe('ReaderPreferencesService', () => {
let service: ReaderPreferencesService;
beforeEach(() => {
vi.clearAllMocks();
mockBookService.verifyFileAccess.mockResolvedValue({ format: 'pdf' });
mockRepo.findPreference.mockResolvedValue(null);
mockRepo.upsertPreference.mockResolvedValue(undefined);
mockRepo.deletePreference.mockResolvedValue(undefined);
mockRepo.findAllDefaults.mockResolvedValue([]);
mockRepo.upsertDefault.mockResolvedValue(undefined);
mockRepo.deleteDefault.mockResolvedValue(undefined);
service = new ReaderPreferencesService(mockRepo as unknown as ReaderPreferencesRepository, mockBookService as unknown as BookService);
});
it('verifies file access before loading a per-book preference', async () => {
const user = makeUser({ id: 12 });
mockRepo.findPreference.mockResolvedValueOnce({ settings: { zoomMode: 'fit-width' } });
const result = await service.getPreference(user, 42);
expect(mockBookService.verifyFileAccess).toHaveBeenCalledWith(42, user);
expect(mockRepo.findPreference).toHaveBeenCalledWith(12, 42);
expect(result).toEqual({ settings: { zoomMode: 'fit-width' } });
});
it('does not query preferences when file access check fails', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockRejectedValueOnce(new ForbiddenException());
await expect(service.getPreference(user, 99)).rejects.toThrow(ForbiddenException);
expect(mockRepo.findPreference).not.toHaveBeenCalled();
});
it('accepts valid partial per-book settings for the inferred file format group', async () => {
const user = makeUser({ id: 4 });
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'pdf' });
await service.upsertPreference(user, 10, { zoomMode: 'custom', customScale: 1.4 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(4, 10, { zoomMode: 'custom', customScale: 1.4 });
});
it('maps cbz file format to cbx group and validates against comics settings', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'cbz' });
await service.upsertPreference(user, 11, { fitMode: 'fit-page', direction: 'rtl' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 11, { fitMode: 'fit-page', direction: 'rtl' });
});
it('rejects per-book settings with unknown keys', async () => {
const user = makeUser();
await expect(service.upsertPreference(user, 12, { unknownSetting: true })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
});
it('rejects per-book settings with invalid values', async () => {
const user = makeUser();
await expect(service.upsertPreference(user, 13, { customScale: 10 })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
});
it('verifies file access before deleting a per-book preference', async () => {
const user = makeUser({ id: 22 });
await service.deletePreference(user, 77);
expect(mockBookService.verifyFileAccess).toHaveBeenCalledWith(77, user);
expect(mockRepo.deletePreference).toHaveBeenCalledWith(22, 77);
});
it('returns all defaults for a user', async () => {
mockRepo.findAllDefaults.mockResolvedValueOnce([{ formatGroup: 'pdf', settings: validPdfDefaults }]);
const result = await service.getAllDefaults(55);
expect(mockRepo.findAllDefaults).toHaveBeenCalledWith(55);
expect(result).toEqual([{ formatGroup: 'pdf', settings: validPdfDefaults }]);
});
it('normalizes format group values when writing defaults', async () => {
await service.upsertDefault(3, 'PDF', validPdfDefaults);
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(3, 'pdf', validPdfDefaults);
});
it('rejects default writes for invalid format groups', async () => {
await expect(service.upsertDefault(3, 'video', validPdfDefaults)).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertDefault).not.toHaveBeenCalled();
});
it('rejects partial payloads for defaults', async () => {
await expect(service.upsertDefault(3, 'pdf', { zoomMode: 'fit-page' })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertDefault).not.toHaveBeenCalled();
});
it('normalizes format group values when deleting defaults', async () => {
await service.deleteDefault(9, 'AuDiO');
expect(mockRepo.deleteDefault).toHaveBeenCalledWith(9, 'audio');
});
});
@@ -1,30 +1,117 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { READER_GROUP_DEFAULTS, getFormatGroup, type ReaderFormatGroup } from '@projectx/types';
import { z } from 'zod';
import type { RequestUser } from '../../common/types/request-user';
import { BookService } from '../book/book.service';
import { ReaderPreferencesRepository } from './reader-preferences.repository';
const VALID_FORMAT_GROUPS = ['epub', 'pdf', 'cbx', 'audio'] as const;
type FormatGroup = (typeof VALID_FORMAT_GROUPS)[number];
const VALID_FORMAT_GROUPS = Object.keys(READER_GROUP_DEFAULTS) as ReaderFormatGroup[];
const VALID_FORMAT_GROUPS_SET = new Set(VALID_FORMAT_GROUPS);
function assertValidFormatGroup(formatGroup: string): asserts formatGroup is FormatGroup {
if (!VALID_FORMAT_GROUPS.includes(formatGroup as FormatGroup)) {
const EPUB_SETTINGS_SCHEMA = z
.object({
themeName: z.string().min(1),
isDark: z.boolean(),
fontFamily: z.string().min(1).nullable(),
fontSize: z.number().min(10).max(32),
lineHeight: z.number().min(0.8).max(3),
maxColumnCount: z.number().int().min(1).max(10),
gap: z.number().min(0).max(0.5),
maxInlineSize: z.number().int().min(400).max(1600),
maxBlockSize: z.number().int().min(600).max(2400),
justify: z.boolean(),
hyphenate: z.boolean(),
flow: z.enum(['paginated', 'scrolled']),
overrideBookFormatting: z.boolean(),
})
.strict();
const PDF_SETTINGS_SCHEMA = z
.object({
scrollMode: z.enum(['vertical', 'horizontal', 'wrapped', 'page']),
spread: z.enum(['none', 'odd', 'even']),
zoomMode: z.enum(['fit-width', 'fit-page', 'custom']),
customScale: z.number().min(0.25).max(4),
rotation: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]),
})
.strict();
const CBX_SETTINGS_SCHEMA = z
.object({
fitMode: z.enum(['fit-page', 'fit-width', 'fit-height', 'actual']),
viewMode: z.enum(['single', 'two-page']),
scrollMode: z.enum(['paginated', 'infinite', 'long-strip']),
direction: z.enum(['ltr', 'rtl']),
bgColor: z.enum(['black', 'gray', 'white']),
})
.strict();
const AUDIO_SETTINGS_SCHEMA = z
.object({
playbackSpeed: z.number().min(0.5).max(3),
volume: z.number().min(0).max(1),
skipBackSeconds: z.number().int().min(0),
skipForwardSeconds: z.number().int().min(0),
})
.strict();
const FULL_SETTINGS_SCHEMA_BY_GROUP = {
epub: EPUB_SETTINGS_SCHEMA,
pdf: PDF_SETTINGS_SCHEMA,
cbx: CBX_SETTINGS_SCHEMA,
audio: AUDIO_SETTINGS_SCHEMA,
} satisfies Record<ReaderFormatGroup, z.ZodTypeAny>;
const PARTIAL_SETTINGS_SCHEMA_BY_GROUP = {
epub: EPUB_SETTINGS_SCHEMA.partial(),
pdf: PDF_SETTINGS_SCHEMA.partial(),
cbx: CBX_SETTINGS_SCHEMA.partial(),
audio: AUDIO_SETTINGS_SCHEMA.partial(),
} satisfies Record<ReaderFormatGroup, z.ZodTypeAny>;
function normalizeFormatGroup(formatGroup: string): ReaderFormatGroup {
const normalized = formatGroup.trim().toLowerCase();
if (!VALID_FORMAT_GROUPS_SET.has(normalized as ReaderFormatGroup)) {
throw new BadRequestException(`Invalid format group "${formatGroup}". Must be one of: ${VALID_FORMAT_GROUPS.join(', ')}`);
}
return normalized as ReaderFormatGroup;
}
@Injectable()
export class ReaderPreferencesService {
constructor(private readonly repo: ReaderPreferencesRepository) {}
constructor(
private readonly repo: ReaderPreferencesRepository,
private readonly bookService: BookService,
) {}
async getPreference(userId: number, bookFileId: number) {
return this.repo.findPreference(userId, bookFileId);
private validateSettings(formatGroup: ReaderFormatGroup, settings: Record<string, unknown>, allowPartial: boolean): Record<string, unknown> {
const schema = allowPartial ? PARTIAL_SETTINGS_SCHEMA_BY_GROUP[formatGroup] : FULL_SETTINGS_SCHEMA_BY_GROUP[formatGroup];
const result = schema.safeParse(settings);
if (!result.success) {
const firstIssue = result.error.issues[0];
const issuePath = firstIssue?.path.length ? firstIssue.path.join('.') : 'settings';
const issueMessage = firstIssue?.message ?? 'Invalid settings payload';
throw new BadRequestException(`Invalid ${formatGroup} reader settings at "${issuePath}": ${issueMessage}`);
}
return result.data as Record<string, unknown>;
}
async upsertPreference(userId: number, bookFileId: number, settings: Record<string, unknown>) {
await this.repo.upsertPreference(userId, bookFileId, settings);
async getPreference(user: RequestUser, bookFileId: number) {
await this.bookService.verifyFileAccess(bookFileId, user);
return this.repo.findPreference(user.id, bookFileId);
}
async deletePreference(userId: number, bookFileId: number) {
await this.repo.deletePreference(userId, bookFileId);
async upsertPreference(user: RequestUser, bookFileId: number, settings: Record<string, unknown>) {
const file = await this.bookService.verifyFileAccess(bookFileId, user);
const formatGroup = getFormatGroup(file.format ?? '');
const validatedSettings = this.validateSettings(formatGroup, settings, true);
await this.repo.upsertPreference(user.id, bookFileId, validatedSettings);
}
async deletePreference(user: RequestUser, bookFileId: number) {
await this.bookService.verifyFileAccess(bookFileId, user);
await this.repo.deletePreference(user.id, bookFileId);
}
async getAllDefaults(userId: number) {
@@ -32,12 +119,13 @@ export class ReaderPreferencesService {
}
async upsertDefault(userId: number, formatGroup: string, settings: Record<string, unknown>) {
assertValidFormatGroup(formatGroup);
await this.repo.upsertDefault(userId, formatGroup, settings);
const normalizedGroup = normalizeFormatGroup(formatGroup);
const validatedSettings = this.validateSettings(normalizedGroup, settings, false);
await this.repo.upsertDefault(userId, normalizedGroup, validatedSettings);
}
async deleteDefault(userId: number, formatGroup: string) {
assertValidFormatGroup(formatGroup);
await this.repo.deleteDefault(userId, formatGroup);
const normalizedGroup = normalizeFormatGroup(formatGroup);
await this.repo.deleteDefault(userId, normalizedGroup);
}
}
@@ -609,7 +609,7 @@ describe('Auth session security (e2e)', () => {
},
});
expect(refreshAfterPasswordChange.statusCode).toBe(401);
});
}, 15_000);
it('keeps sessions active when current password is invalid', async () => {
const credentials = await createLocalUser(context.db, { password: 'OldPass123' });
@@ -639,7 +639,7 @@ describe('Auth session security (e2e)', () => {
},
});
expect(refreshResponse.statusCode).toBe(200);
});
}, 15_000);
});
describe('session revocation endpoint', () => {
@@ -723,6 +723,6 @@ describe('Auth session security (e2e)', () => {
},
});
expect(revokedSessionRefresh.statusCode).toBe(401);
});
}, 15_000);
});
});