feat(books): remember a default read-aloud narration language

Resolve book languages stated as names ("English") to a code, so the
dialog and the server's fallback stop calling English unsupported. The
dialog groups the ebook and audiobook in a Sources card, shows
Highlighting without the Advanced disclosure, and saves a per-user
default under /user-preferences/read-aloud for books it cannot match.
This commit is contained in:
neon
2026-10-22 00:42:06 +00:00
parent 0320210767
commit 8810d47658
16 changed files with 599 additions and 178 deletions
@@ -0,0 +1,31 @@
import { languageSubtagOf, READ_ALOUD_QUARTZNET_LANGUAGES } from '@bookorbit/types';
import { describe, expect, it } from 'vitest';
describe('languageSubtagOf', () => {
it.each([
['English', 'en'],
['english', 'en'],
['en', 'en'],
['en-US', 'en'],
['eng', 'en'],
['ja-jp', 'ja'],
['Spanish', 'es'],
['español', 'es'],
['Deutsch', 'de'],
['ger', 'de'],
])('reads %s as %s', (value, code) => {
expect(languageSubtagOf(value)).toBe(code);
});
it('names nothing for empty, undetermined and unrecognised values', () => {
for (const value of [null, undefined, '', ' ', 'und', 'UND', 'mul', 'zxx', 'Klingon', 'not a language']) {
expect(languageSubtagOf(value)).toBeNull();
}
});
it('matches names of languages the request list does not offer when asked to', () => {
expect(languageSubtagOf('Faroese')).toBeNull();
expect(languageSubtagOf('Faroese', READ_ALOUD_QUARTZNET_LANGUAGES)).toBe('fo');
expect(languageSubtagOf('Kazakh', READ_ALOUD_QUARTZNET_LANGUAGES)).toBe('kk');
});
});
@@ -227,6 +227,13 @@ describe('ReadAloudService.create', () => {
expect(jobs.create).not.toHaveBeenCalled();
});
it('reads a book language stated as a name when the request names none', async () => {
const { service, jobs } = makeService({ book: bookDetail({ language: 'English' }) });
await service.create(12, Object.assign(new CreateReadAloudJobDto(), { epubFileId: 101, audioFileIds: [102] }), OWNER);
expect(jobs.create).toHaveBeenCalledWith(expect.objectContaining({ input: expect.objectContaining({ language: 'en' }) }));
});
it('refuses unsupported languages, unmounted libraries, and an unavailable worker', async () => {
await expect(makeService({ book: bookDetail({ language: 'nl-NL' }) }).service.create(12, new CreateReadAloudJobDto(), OWNER)).rejects.toThrow(
BadRequestException,
@@ -21,6 +21,7 @@ import type {
import {
isAudioFormat,
isContentBookFile,
languageSubtagOf,
Permission,
READ_ALOUD_ACTIVE_JOB_STATUSES,
READ_ALOUD_MAX_AUDIO_FILES,
@@ -169,7 +170,7 @@ export class ReadAloudService {
replaceFileId = dto.replaceFileId;
}
const locale = this.parseLocale(dto.language ?? book.language ?? 'en');
const locale = this.parseLocale(dto.language ?? languageSubtagOf(book.language, READ_ALOUD_QUARTZNET_LANGUAGES) ?? 'en');
if (!QUARTZNET_LANGUAGES.has(locale.language)) {
throw new BadRequestException(`QuartzNet15x5 does not support language "${locale.language}"`);
}
@@ -93,6 +93,8 @@ describe('UserPreferencesController', () => {
upsertLocalePreferences: vi.fn(),
upsertCoverSearchPreferences: vi.fn(),
upsertBookRequestPreferences: vi.fn(),
getReadAloudPreferences: vi.fn(),
upsertReadAloudPreferences: vi.fn(),
getAnnotationColorNamePreferences: vi.fn(),
upsertAnnotationColorNamePreferences: vi.fn(),
} as unknown as Mocked<UserPreferencesService>;
@@ -187,6 +189,20 @@ describe('UserPreferencesController', () => {
expect(service.upsertBookRequestPreferences).toHaveBeenCalledWith(42, dto.settings);
});
it("GET /read-aloud returns the current user's saved default language", async () => {
service.getReadAloudPreferences.mockResolvedValueOnce({ defaultLanguage: 'en' });
await expect(controller.getReadAloudPreferences(makeUser({ id: 11 }))).resolves.toEqual({ settings: { defaultLanguage: 'en' } });
expect(service.getReadAloudPreferences).toHaveBeenCalledWith(11);
});
it('PUT /read-aloud forwards the current user id and settings', async () => {
const dto = { settings: { defaultLanguage: 'de' } };
await expect(controller.upsertReadAloudPreferences(dto, makeUser({ id: 42 }))).resolves.toBeUndefined();
expect(service.upsertReadAloudPreferences).toHaveBeenCalledWith(42, dto.settings);
});
it('GET /locale returns null settings when no saved preferences exist', async () => {
service.getLocalePreferences.mockResolvedValueOnce(null);
@@ -57,6 +57,18 @@ export class UserPreferencesController {
await this.userPreferencesService.upsertBookRequestPreferences(user.id, dto.settings);
}
@Get('read-aloud')
async getReadAloudPreferences(@CurrentUser() user: RequestUser) {
const settings = await this.userPreferencesService.getReadAloudPreferences(user.id);
return { settings };
}
@Put('read-aloud')
@HttpCode(204)
async upsertReadAloudPreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
await this.userPreferencesService.upsertReadAloudPreferences(user.id, dto.settings);
}
@Get('locale')
async getLocalePreferences(@CurrentUser() user: RequestUser) {
const settings = await this.userPreferencesService.getLocalePreferences(user.id);
@@ -205,6 +205,34 @@ describe('UserPreferencesService', () => {
expect(repo.upsert).not.toHaveBeenCalled();
});
it('getReadAloudPreferences returns no language when nothing was saved', async () => {
await expect(service.getReadAloudPreferences(7)).resolves.toEqual({ defaultLanguage: null });
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'read-aloud');
});
it('getReadAloudPreferences returns the saved language, and none when the stored row is malformed', async () => {
repo.findByCategory.mockResolvedValueOnce({ data: { defaultLanguage: 'fr' } } as never);
await expect(service.getReadAloudPreferences(7)).resolves.toEqual({ defaultLanguage: 'fr' });
repo.findByCategory.mockResolvedValueOnce({ data: { defaultLanguage: 'English' } } as never);
await expect(service.getReadAloudPreferences(7)).resolves.toEqual({ defaultLanguage: null });
});
it('upsertReadAloudPreferences saves and clears the default language', async () => {
await expect(service.upsertReadAloudPreferences(11, { defaultLanguage: 'de' })).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'read-aloud', { defaultLanguage: 'de' });
await expect(service.upsertReadAloudPreferences(11, { defaultLanguage: null })).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenLastCalledWith(11, 'read-aloud', { defaultLanguage: null });
});
it('upsertReadAloudPreferences rejects a language name, a full tag and unknown fields', async () => {
for (const settings of [{ defaultLanguage: 'English' }, { defaultLanguage: 'en-US' }, { defaultLanguage: 'en', unexpected: true }]) {
await expect(service.upsertReadAloudPreferences(11, settings)).rejects.toBeInstanceOf(BadRequestException);
}
expect(repo.upsert).not.toHaveBeenCalled();
});
it('getBookRequestPreferences returns no language when nothing was saved', async () => {
await expect(service.getBookRequestPreferences(7)).resolves.toEqual({ defaultLanguage: null });
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'book-requests');
@@ -18,6 +18,7 @@ import {
COVER_SEARCH_DEFAULT_PROVIDERS,
COVER_SIZE_SCOPES,
DEFAULT_BOOK_REQUEST_PREFERENCES,
DEFAULT_READ_ALOUD_PREFERENCES,
RETIRED_BOOK_REQUEST_PREFERENCE_FIELDS,
DEFAULT_COVER_SEARCH_PROVIDER,
FONT_FAMILY_NAME_MAX_LENGTH,
@@ -43,6 +44,7 @@ import {
type LocalePreferences,
type PodcastPlaybackPreferences,
type PodcastPlaylistPreferences,
type ReadAloudPreferences,
type ServerFontPreferences,
type ThemePreferences,
type WhatsNewPreferences,
@@ -151,6 +153,20 @@ const BOOK_REQUEST_PREFERENCES_SCHEMA = z.preprocess(
.strict(),
);
/**
* Any well-formed language subtag, not only the current speech model's: the model's language list
* can grow, and the dialog ignores a stored language it cannot offer.
*/
const READ_ALOUD_PREFERENCES_SCHEMA = z
.object({
defaultLanguage: z
.string()
.regex(/^[a-z]{2,3}$/, 'must be a lowercase language subtag')
.nullable()
.default(null),
})
.strict() satisfies z.ZodType<ReadAloudPreferences>;
const LOCALE_PREFERENCES_SCHEMA = z
.object({
locale: z.enum(SUPPORTED_LOCALES),
@@ -472,6 +488,24 @@ export class UserPreferencesService {
await this.repo.upsert(userId, 'book-requests', result.data);
}
async getReadAloudPreferences(userId: number): Promise<ReadAloudPreferences> {
const row = await this.repo.findByCategory(userId, 'read-aloud');
const result = READ_ALOUD_PREFERENCES_SCHEMA.safeParse(row?.data);
return result.success ? result.data : { ...DEFAULT_READ_ALOUD_PREFERENCES };
}
async upsertReadAloudPreferences(userId: number, data: Record<string, unknown>): Promise<void> {
const result = READ_ALOUD_PREFERENCES_SCHEMA.safeParse(data);
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 read-aloud preferences at "${issuePath}": ${issueMessage}`);
}
await this.repo.upsert(userId, 'read-aloud', result.data);
}
async upsertThemePreferences(userId: number, data: Record<string, unknown>): Promise<void> {
const start = Date.now();
this.logger.log(`[user_preferences.upsert_theme] [start] userId=${userId} - upsert theme preferences started`);
@@ -1,7 +1,7 @@
{
"totalRoutes": 677,
"totalRoutes": 679,
"byPermission": {
"(none)": 287,
"(none)": 289,
"Permission.ManageMetadataConfig": 41,
"Permission.EmailSend": 34,
"Permission.LibraryEditMetadata": 37,
@@ -814,5 +814,29 @@
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/user-preferences/user-preferences.controller.ts",
"className": "UserPreferencesController",
"methodName": "getReadAloudPreferences",
"httpMethod": "GET",
"path": "/user-preferences/read-aloud",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/user-preferences/user-preferences.controller.ts",
"className": "UserPreferencesController",
"methodName": "upsertReadAloudPreferences",
"httpMethod": "PUT",
"path": "/user-preferences/read-aloud",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
}
]