mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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": []
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user