mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(metadata): harden preference concurrency and score recalculation
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user