fix(reader): keep narration markers, answer TTS requests in shape, and bound slow paths

Keep the narration marker a reading progress write leaves out. The
save turned an omitted positionSeconds, mediaOverlayFragment or
mediaOverlaySectionIndex into null and wrote it, so any client saving
only its text position erased the marker another client had left. An
explicit null still clears it. The audiobook-to-ebook sync now moves
narrationUpdatedAt and the narration percentage with the marker it
writes, so a comparison of stamps no longer prefers an older text
position.

Answer TTS requests as their types say. A position never saved is
JSON null rather than an empty 200 body, and the book preferences PUT
answers providerId, voiceId, speed and bookId, with the provider as a
string. TtsPosition gains the updatedAt the route has always sent.

Refuse with a 400 what used to reach the database as a 500: a null
speed for the account (now left unchanged), a provider id that names
no provider, a voiceId over 200 characters, a cfi, fragment or Kobo
location longer than its column, and a fractional or oversized
section index. Voices for a provider id that is not a number answer
404.

Match the reader's blocks in chapter text: tag names now match whole,
so <picture>, <param>, <link>, <track> and SVG shapes no longer open
blocks foliate does not count. The chapter href is percent-decoded,
and cached text is keyed by the file's modified time.

Build a narration playlist once per copy of the file instead of on
every overlay audio request, answer an unsatisfiable range with 416
and ignore an unreadable Range header. The iOS app now plays online
read-along audio from this route in 512 KB ranges, so it is asked
several times per clip.

Give plain synthesis the captioned path's 120 s limit beside the
client's own cancel signal, and voice listing and connection tests a
30 s one.
This commit is contained in:
neon
2026-10-13 21:12:17 +00:00
parent ef3e42ee33
commit 294bb13987
15 changed files with 313 additions and 52 deletions
+2
View File
@@ -58,6 +58,8 @@ export type TtsPlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'erro
export interface TtsPosition {
cfi: string;
chapterIndex: number | null;
/** When the position was saved. Clients compare it with their own copy. */
updatedAt?: string;
}
// Synthesis
@@ -757,6 +757,58 @@ describe('BookRepository', () => {
expect(resetWhere).toHaveBeenCalledOnce();
});
it('moves the narration stamp with a bridged marker', async () => {
const returning = vi.fn().mockResolvedValue([{ fileId: 20 }]);
const onConflictDoUpdate = vi.fn().mockReturnValue({ returning });
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
const db = {
insert: vi.fn().mockReturnValue({ values }),
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
};
const repo = new BookRepository(db as never);
const sourceUpdatedAt = new Date('2026-10-13T12:00:00.000Z');
const base = {
userId: 7,
fileId: 20,
cfi: 'epubcfi(/6/4)',
percentage: 42,
mediaOverlayFragment: 'OPS/chapter.xhtml#p2',
mediaOverlaySectionIndex: 1,
koreaderProgress: null,
sourceUpdatedAt,
};
await repo.upsertSyncedEpubProgressIfNewer({ ...base, positionSeconds: 120 });
const stamp = { narrationPercentage: 42, narrationUpdatedAt: sourceUpdatedAt };
expect(values).toHaveBeenLastCalledWith(expect.objectContaining(stamp));
expect(onConflictDoUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ set: expect.objectContaining(stamp) }));
await repo.upsertSyncedEpubProgressIfNewer({ ...base, positionSeconds: null });
expect(values.mock.lastCall![0]).not.toHaveProperty('narrationUpdatedAt');
expect(onConflictDoUpdate.mock.lastCall![0].set).not.toHaveProperty('narrationUpdatedAt');
});
it('keeps the stored narration marker when a write leaves it out, and clears it on an explicit null', async () => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
const db = {
insert: vi.fn().mockReturnValue({ values }),
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
};
const repo = new BookRepository(db as never);
await repo.upsertProgress(5, 9, 'epubcfi(/6/2)', null, 40);
const kept = onConflictDoUpdate.mock.lastCall![0].set;
expect(kept).not.toHaveProperty('positionSeconds');
expect(kept).not.toHaveProperty('mediaOverlayFragment');
expect(kept).not.toHaveProperty('mediaOverlaySectionIndex');
await repo.upsertProgress(5, 9, 'epubcfi(/6/2)', null, 40, null, null, null);
expect(onConflictDoUpdate.mock.lastCall![0].set).toEqual(
expect.objectContaining({ positionSeconds: null, mediaOverlayFragment: null, mediaOverlaySectionIndex: null }),
);
});
it('clears a stale narration marker when bridging to a copy without media overlays', async () => {
const returning = vi.fn().mockResolvedValue([{ fileId: 20 }]);
const onConflictDoUpdate = vi.fn().mockReturnValue({ returning });
+16 -3
View File
@@ -2408,6 +2408,13 @@ export class BookRepository {
? { narrationPercentage: this.clampProgressPercentage(narration.percentage), narrationUpdatedAt: narration.updatedAt }
: {};
const textColumns = textUpdatedAt ? { textUpdatedAt } : {};
// A write that leaves out the narration marker keeps the one stored: a client that saves only
// its text position used to erase the marker another client had left. An explicit null clears.
const markerColumns = {
...(positionSeconds !== undefined ? { positionSeconds } : {}),
...(mediaOverlayFragment !== undefined ? { mediaOverlayFragment } : {}),
...(mediaOverlaySectionIndex !== undefined ? { mediaOverlaySectionIndex } : {}),
};
await this.db
.insert(readingProgress)
.values({
@@ -2434,9 +2441,7 @@ export class BookRepository {
cfi,
pageNumber,
percentage,
positionSeconds: positionSeconds ?? null,
mediaOverlayFragment: mediaOverlayFragment ?? null,
mediaOverlaySectionIndex: mediaOverlaySectionIndex ?? null,
...markerColumns,
koboLocationSource: normalizedKoboLocationSource,
koboLocationType: normalizedKoboLocationType,
koboLocationValue: normalizedKoboLocationValue,
@@ -2467,6 +2472,12 @@ export class BookRepository {
koreaderProgress: string | null;
sourceUpdatedAt: Date;
}): Promise<boolean> {
// A marker moved here moves the narration's stamp with it, or a comparison of stamps would
// prefer the text position over a newer marker.
const narrationColumns =
params.positionSeconds != null
? { narrationPercentage: this.clampProgressPercentage(params.percentage), narrationUpdatedAt: params.sourceUpdatedAt }
: {};
const rows = await this.db
.insert(readingProgress)
.values({
@@ -2482,6 +2493,7 @@ export class BookRepository {
updatedAt: params.sourceUpdatedAt,
lastReadAt: params.sourceUpdatedAt,
textUpdatedAt: params.sourceUpdatedAt,
...narrationColumns,
})
.onConflictDoUpdate({
target: [readingProgress.bookFileId, readingProgress.userId],
@@ -2496,6 +2508,7 @@ export class BookRepository {
updatedAt: params.sourceUpdatedAt,
lastReadAt: params.sourceUpdatedAt,
textUpdatedAt: params.sourceUpdatedAt,
...narrationColumns,
},
setWhere: lte(readingProgress.updatedAt, params.sourceUpdatedAt),
})
+9 -9
View File
@@ -3170,8 +3170,8 @@ describe('BookService', () => {
null,
25,
900,
null,
null,
undefined,
undefined,
null,
null,
null,
@@ -3182,7 +3182,7 @@ describe('BookService', () => {
);
});
it('passes null positionSeconds when not provided in DTO', async () => {
it('leaves the narration marker out when the DTO omits it', async () => {
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser();
@@ -3199,9 +3199,9 @@ describe('BookService', () => {
null,
null,
50,
null,
null,
null,
undefined,
undefined,
undefined,
null,
null,
null,
@@ -3237,9 +3237,9 @@ describe('BookService', () => {
null,
null,
50,
null,
null,
null,
undefined,
undefined,
undefined,
null,
null,
null,
+4 -3
View File
@@ -2299,9 +2299,10 @@ export class BookService {
text.cfi,
text.pageNumber,
text.percentage,
dto.positionSeconds ?? null,
dto.mediaOverlayFragment ?? null,
dto.mediaOverlaySectionIndex ?? null,
// Omitted, these keep the narration marker the file holds; only an explicit null clears it.
dto.positionSeconds,
dto.mediaOverlayFragment,
dto.mediaOverlaySectionIndex,
dto.koboLocationSource ?? null,
dto.koboLocationType ?? null,
dto.koboLocationValue ?? null,
@@ -1,4 +1,4 @@
import { IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min, ValidateIf } from 'class-validator';
import { IsIn, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min, ValidateIf } from 'class-validator';
/**
* Which of a file's two positions this write moved. Text is the default so a client that predates
@@ -12,8 +12,10 @@ export class SaveProgressDto {
@IsIn(PROGRESS_SOURCES)
source?: ProgressSource;
// Each string is held to its column, so an oversized one is a 400 rather than a database error.
@ValidateIf((o: SaveProgressDto) => o.cfi != null)
@IsString()
@MaxLength(2000)
cfi?: string | null;
@ValidateIf((o: SaveProgressDto) => o.pageNumber != null)
@@ -35,24 +37,29 @@ export class SaveProgressDto {
@ValidateIf((o: SaveProgressDto) => o.mediaOverlayFragment != null)
@IsString()
@MaxLength(4096)
mediaOverlayFragment?: string | null;
@IsOptional()
@ValidateIf((o: SaveProgressDto) => o.mediaOverlaySectionIndex != null)
@IsNumber()
@IsInt()
@Min(0)
@Max(2147483647)
mediaOverlaySectionIndex?: number | null;
@ValidateIf((o: SaveProgressDto) => o.koboLocationSource != null)
@IsString()
@MaxLength(4096)
koboLocationSource?: string | null;
@ValidateIf((o: SaveProgressDto) => o.koboLocationType != null)
@IsString()
@MaxLength(64)
koboLocationType?: string | null;
@ValidateIf((o: SaveProgressDto) => o.koboLocationValue != null)
@IsString()
@MaxLength(255)
koboLocationValue?: string | null;
@ValidateIf((o: SaveProgressDto) => o.koreaderProgress != null)
@@ -363,6 +363,32 @@ describe('EpubService', () => {
expect(result.data).toEqual(Buffer.from('2345'));
});
// A player asks for many ranges of one clip. Each used to parse every SMIL in the book again.
it('builds the playlist once for every range of a clip', async () => {
mockOpenFile.mockImplementation(() => Promise.resolve(makeMediaOverlayArchive() as any));
await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, 'bytes=0-1', user);
const opensForFirst = mockOpenFile.mock.calls.length;
const second = await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, 'bytes=2-3', user);
expect(mockOpenFile.mock.calls.length - opensForFirst).toBe(1);
expect(second.data).toEqual(Buffer.from('23'));
});
it('answers a range past the end with 416 and ignores a header it cannot read', async () => {
mockOpenFile.mockImplementation(() => Promise.resolve(makeMediaOverlayArchive() as any));
const beyond = await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, 'bytes=20-30', user);
expect(beyond.status).toBe(416);
expect(beyond.contentRange).toBe('bytes */10');
expect(beyond.data.length).toBe(0);
const unreadable = await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, 'bytes=abc', user);
expect(unreadable.status).toBe(200);
expect(unreadable.contentRange).toBeNull();
expect(unreadable.data.length).toBe(10);
});
it('rejects media-overlay file requests for non-playlist resources', async () => {
mockOpenFile.mockResolvedValueOnce(makeMediaOverlayArchive() as any).mockResolvedValueOnce(makeMediaOverlayArchive() as any);
+44 -10
View File
@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { stat } from 'fs/promises';
import * as unzipper from 'unzipper';
import { XMLParser } from 'fast-xml-parser';
@@ -52,6 +52,8 @@ interface CacheEntry {
mtime: number;
validPaths: Set<string>;
lastAccessed: number;
/** Narration playlists built from this copy of the file, by `bookId:fileId`. */
playlists?: Map<string, Promise<EpubMediaOverlayPlaylist>>;
}
interface EpubFileResolution {
@@ -328,7 +330,26 @@ export class EpubService {
async getMediaOverlayPlaylist(bookId: number, fileId: number | undefined, user: RequestUser): Promise<EpubMediaOverlayPlaylist> {
const resolved = await this.resolveEpubFile(bookId, fileId, user);
const cached = await this.getCachedEntry(resolved.absolutePath);
return buildEpubMediaOverlayPlaylist(resolved.absolutePath, cached.info, bookId, resolved.fileId);
return this.cachedPlaylist(cached, resolved.absolutePath, bookId, resolved.fileId);
}
/**
* The playlist, built once per copy of the file. Every audio request needs it to check its
* resource, and a player asks for many ranges of one clip, each of which used to parse every
* SMIL in the book again.
*/
private cachedPlaylist(entry: CacheEntry, absolutePath: string, bookId: number, fileId: number | null): Promise<EpubMediaOverlayPlaylist> {
const playlists = (entry.playlists ??= new Map());
const key = `${bookId}:${fileId ?? ''}`;
const existing = playlists.get(key);
if (existing) return existing;
const built = buildEpubMediaOverlayPlaylist(absolutePath, entry.info, bookId, fileId);
playlists.set(key, built);
// A failure is not kept, so the next request tries again.
built.catch(() => {
if (playlists.get(key) === built) playlists.delete(key);
});
return built;
}
async streamMediaOverlayFile(
@@ -344,7 +365,7 @@ export class EpubService {
const resolved = await this.resolveEpubFile(bookId, fileId, user);
const cached = await this.getCachedEntry(resolved.absolutePath);
const playlist = await buildEpubMediaOverlayPlaylist(resolved.absolutePath, cached.info, bookId, resolved.fileId);
const playlist = await this.cachedPlaylist(cached, resolved.absolutePath, bookId, resolved.fileId);
const resource = playlist.resources.find((item) => item.href === normalizedPath);
if (!resource) throw new NotFoundException(`Media-overlay resource not in playlist: ${normalizedPath}`);
@@ -354,6 +375,15 @@ export class EpubService {
const full = await entry.buffer();
const range = this.parseRange(rangeHeader, full.length);
if (range === 'unsatisfiable') {
return {
data: Buffer.alloc(0),
contentType: resource.mediaType,
size: full.length,
status: 416,
contentRange: `bytes */${full.length}`,
};
}
const data = range ? full.subarray(range.start, range.end + 1) : full;
return {
data,
@@ -413,27 +443,31 @@ export class EpubService {
return { fileId: null, absolutePath: file.absolutePath, readerPath: file.absolutePath, sizeBytes: file.sizeBytes };
}
private parseRange(rangeHeader: string | undefined, size: number): ByteRange | null {
/**
* The bytes a Range header asks for. A header this cannot read is ignored and the whole file
* sent, as HTTP has it; a range that reads but lies outside the file is a 416.
*/
private parseRange(rangeHeader: string | undefined, size: number): ByteRange | 'unsatisfiable' | null {
if (!rangeHeader) return null;
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) throw new BadRequestException('Invalid Range header');
if (!match) return null;
const [, startRaw, endRaw] = match;
if (!startRaw && !endRaw) throw new BadRequestException('Invalid Range header');
if (!startRaw && !endRaw) return null;
let start: number;
let end: number;
if (!startRaw) {
const suffixLength = Number(endRaw);
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) throw new BadRequestException('Invalid Range header');
if (!Number.isSafeInteger(suffixLength)) return null;
if (suffixLength <= 0 || size === 0) return 'unsatisfiable';
start = Math.max(0, size - suffixLength);
end = size - 1;
} else {
start = Number(startRaw);
end = endRaw ? Number(endRaw) : size - 1;
}
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
throw new BadRequestException('Invalid Range header');
}
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) return null;
if (start >= size) return 'unsatisfiable';
return { start, end: Math.min(end, size - 1) };
}
@@ -1,4 +1,4 @@
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class UpdateTtsPreferencesDto {
@IsOptional()
@@ -7,6 +7,7 @@ export class UpdateTtsPreferencesDto {
@IsOptional()
@IsString()
@MaxLength(200)
voiceId?: string;
@IsOptional()
@@ -119,11 +119,26 @@ describe('OpenAiCompatibleProvider', () => {
it('passes the abort signal to the upstream request', async () => {
const provider = makeProvider();
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse(null));
const { signal } = new AbortController();
const controller = new AbortController();
await provider.synthesize('hello', 'alloy', 1.0, 'mp3', signal);
await provider.synthesize('hello', 'alloy', 1.0, 'mp3', controller.signal);
expect((fetchSpy.mock.calls[0]![1] as RequestInit).signal).toBe(signal);
const sent = (fetchSpy.mock.calls[0]![1] as RequestInit).signal!;
expect(sent.aborted).toBe(false);
controller.abort();
expect(sent.aborted).toBe(true);
});
// A provider that never answered held the request for as long as Node allowed.
it('gives up on a provider that does not answer in time', async () => {
const provider = makeProvider();
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse(null));
await provider.synthesize('hello', 'alloy', 1.0, 'mp3');
expect(timeoutSpy).toHaveBeenCalledWith(120_000);
expect((fetchSpy.mock.calls[0]![1] as RequestInit).signal).toBe(timeoutSpy.mock.results[0]!.value);
});
it('does not log a cancelled request as an error', async () => {
@@ -25,6 +25,11 @@ interface CaptionedSpeechRequest extends OpenAiSpeechRequest {
const CAPTIONED_SPEECH_PATH = '/dev/captioned_speech';
const CAPTIONED_REQUEST_TIMEOUT_MS = 120_000;
/** Plain synthesis is held to the captioned path's limit: a provider that never answers would
* otherwise hold the request, and the reader waiting on it, for as long as Node lets it. */
const SPEECH_REQUEST_TIMEOUT_MS = 120_000;
/** Listing voices and testing a connection should answer in seconds. */
const DISCOVERY_REQUEST_TIMEOUT_MS = 30_000;
const CAPTIONED_MAX_RESPONSE_BYTES = 24 * 1024 * 1024;
export interface StaticVoiceConfig {
@@ -103,11 +108,12 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
speed: Math.min(4.0, Math.max(0.25, speed)),
response_format: format === 'mp3' ? 'mp3' : format,
};
const timeout = AbortSignal.timeout(SPEECH_REQUEST_TIMEOUT_MS);
const response = await fetch(`${this.baseUrl}/audio/speech`, {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
signal,
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
@@ -249,6 +255,7 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
try {
const response = await fetch(`${this.baseUrl}/audio/voices`, {
headers: this.buildHeaders(),
signal: AbortSignal.timeout(DISCOVERY_REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
if (response.status === 404) {
@@ -292,6 +299,7 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
try {
const response = await fetch(`${this.baseUrl}/models`, {
headers: this.buildHeaders(),
signal: AbortSignal.timeout(DISCOVERY_REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
// The voice listing is the fallback reachability probe, so it has to hit the network here
@@ -308,6 +316,7 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
async discoverVoicesLive(): Promise<TtsVoice[] | null> {
const response = await fetch(`${this.baseUrl}/audio/voices`, {
headers: this.buildHeaders(),
signal: AbortSignal.timeout(DISCOVERY_REQUEST_TIMEOUT_MS),
});
if (response.status === 404) return null;
if (!response.ok) {
@@ -111,6 +111,20 @@ describe('htmlToBlocks', () => {
});
});
describe('htmlToBlocks tag names', () => {
// `p`, `li` and `tr` matched as prefixes, so these opened blocks Foliate does not count and
// every later block's index ran ahead of the reader's highlight.
it('starts no block at an element whose name only begins like a block', () => {
const html =
'<body><p>One <picture><source srcset="a.webp"/></picture> two</p><link rel="x"/><object><param name="a"/></object><svg><path d="M0"/><line x1="0"/></svg><p>Three</p><video><track src="t.vtt"/></video><p>Four</p></body>';
expect(htmlToBlocks(html)).toEqual(['One two', 'Three', 'Four']);
});
it('still opens a block at a block element with attributes, a newline or a self-closing slash', () => {
expect(htmlToBlocks('<body><p class="a">One</p><p\n>Two</p><hr/><div>Three</div></body>')).toEqual(['One', 'Two', 'Three']);
});
});
describe('normalizePath', () => {
it('returns a simple path unchanged', () => {
expect(normalizePath('OEBPS/Text/ch1.xhtml')).toBe('OEBPS/Text/ch1.xhtml');
@@ -1,5 +1,6 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { load } from 'cheerio';
import { stat } from 'node:fs/promises';
import * as unzipper from 'unzipper';
import { XMLParser } from 'fast-xml-parser';
@@ -34,8 +35,11 @@ export function htmlToBlocks(html: string): string[] {
// Keep this block list aligned with Foliate's TTS block segmentation so
// server-side audio blocks and client-side highlight blocks stay in sync.
// Whole tag names only: without the lookahead `p` also opened a block at
// <picture>, <param> and an SVG <path>, `li` at <link> and `tr` at <track>,
// none of which Foliate counts.
const BLOCK_SPLIT_RE =
/<(?:article|aside|audio|blockquote|caption|details|dialog|div|dl|dt|dd|figure|footer|form|figcaption|h[1-6]|header|hgroup|hr|li|main|math|nav|ol|p|pre|section|tr)[^>]*>/gi;
/<(?:article|aside|audio|blockquote|caption|details|dialog|div|dl|dt|dd|figure|footer|form|figcaption|h[1-6]|header|hgroup|hr|li|main|math|nav|ol|p|pre|section|tr)(?=[\s/>])[^>]*>/gi;
// Foliate's getBlocks() starts the first block at the first block-level
// element and discards any content before it. Anchor extraction at the same
@@ -76,6 +80,14 @@ function blockText(chunk: string): string {
return load(chunk, undefined, false).root().text().replace(/\s+/g, ' ').trim();
}
function decodeHref(href: string): string {
try {
return decodeURIComponent(href);
} catch {
return href;
}
}
@Injectable()
export class TtsTextExtractorService {
private readonly logger = new Logger(TtsTextExtractorService.name);
@@ -84,22 +96,25 @@ export class TtsTextExtractorService {
constructor(private readonly bookReadService: BookReadService) {}
async extractChapterText(bookFileId: number, chapterIndex: number): Promise<TtsChapterText> {
const cacheKey = `${bookFileId}:${chapterIndex}`;
const cached = this.textCache.get(cacheKey);
if (cached) {
cached.lastAccessed = Date.now();
return cached.text;
}
const event = 'tts.text_extractor.extract';
const startMs = Date.now();
this.logger.log(`[${event}] [start] bookFileId=${bookFileId} chapterIndex=${chapterIndex} - extracting chapter text`);
try {
const file = await this.bookReadService.findFileById(bookFileId);
if (!file) throw new NotFoundException(`Book file ${bookFileId} not found`);
if (file.format !== 'epub') throw new NotFoundException(`File ${bookFileId} is not an EPUB`);
// Keyed by when the file last changed, so a replaced EPUB is read afresh rather than
// narrated from the text of the one before it.
const { mtimeMs } = await stat(file.absolutePath);
const cacheKey = `${bookFileId}:${mtimeMs}:${chapterIndex}`;
const cached = this.textCache.get(cacheKey);
if (cached) {
cached.lastAccessed = Date.now();
return cached.text;
}
this.logger.log(`[${event}] [start] bookFileId=${bookFileId} chapterIndex=${chapterIndex} - extracting chapter text`);
const result = await this.extractFromEpub(file.absolutePath, chapterIndex);
this.logger.log(
`[${event}] [end] bookFileId=${bookFileId} chapterIndex=${chapterIndex} durationMs=${Date.now() - startMs} blocks=${result.sentences.length} - chapter text extracted`,
@@ -165,7 +180,8 @@ export class TtsTextExtractorService {
if (!href) throw new NotFoundException(`Manifest item ${idref} has no href`);
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
const chapterPath = normalizePath(opfDir + href);
// Manifest hrefs are URLs: `Chapter%201.xhtml` names the archive entry `Chapter 1.xhtml`.
const chapterPath = normalizePath(opfDir + decodeHref(href));
const chapterFile = zip.files.find((f) => f.path === chapterPath);
if (!chapterFile) throw new NotFoundException(`Chapter file not found: ${chapterPath}`);
@@ -343,6 +343,7 @@ describe('TtsService', () => {
describe('saveUserPreferences', () => {
it('should call repo with correct shape', async () => {
repo.findProviderById.mockResolvedValue(DB_PROVIDER);
await service.saveUserPreferences(42, { providerId: '1', voiceId: 'alloy', speed: 1.5 });
expect(repo.upsertUserPreferences).toHaveBeenCalledWith(42, {
providerId: 1,
@@ -366,10 +367,42 @@ describe('TtsService', () => {
});
it('should return mapped TtsUserPreferences with string providerId', async () => {
repo.findProviderById.mockResolvedValue(DB_PROVIDER);
repo.upsertUserPreferences.mockResolvedValue({ providerId: 3, voiceId: 'alloy', speed: 1.25 });
const result = await service.saveUserPreferences(42, { providerId: '3', voiceId: 'alloy', speed: 1.25 });
expect(result).toEqual({ providerId: '3', voiceId: 'alloy', speed: 1.25 });
});
// The account's speed is NOT NULL, so a null used to reach the database as a 500.
it('leaves the speed alone when it is sent as null', async () => {
await service.saveUserPreferences(42, { voiceId: 'alloy', speed: null as never });
expect(repo.upsertUserPreferences).toHaveBeenCalledWith(42, { voiceId: 'alloy' });
});
// A provider that does not exist failed the foreign key as a 500.
it.each(['99', '1.5', '99999999999'])('refuses a provider %s that names no provider', async (providerId) => {
repo.findProviderById.mockResolvedValue(null);
await expect(service.saveUserPreferences(42, { providerId })).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsertUserPreferences).not.toHaveBeenCalled();
});
});
describe('saveBookPreferences', () => {
it('answers in the documented shape, with the provider as a string', async () => {
repo.findProviderById.mockResolvedValue(DB_PROVIDER);
repo.upsertBookPreferences.mockResolvedValue({
id: 5,
userId: 42,
bookId: 7,
providerId: 3,
voiceId: 'alloy',
speed: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await service.saveBookPreferences(42, 7, { providerId: '3', voiceId: 'alloy' }, USER);
expect(result).toEqual({ providerId: '3', voiceId: 'alloy', speed: null, bookId: 7 });
});
});
describe('TTS position', () => {
@@ -393,6 +426,20 @@ describe('TtsService', () => {
});
});
describe('nothing saved', () => {
// An undefined row went out as an empty 200 body, which is not JSON.
it('answers a position never saved with null', async () => {
repo.findPosition.mockResolvedValue(undefined);
await expect(service.getPosition(42, 10, USER)).resolves.toBeNull();
});
// `Number('abc')` reached the database as NaN, a 500.
it('answers voices for a provider id that is not a number with a 404', async () => {
await expect(service.getVoices('abc')).rejects.toBeInstanceOf(NotFoundException);
expect(repo.findProviderById).not.toHaveBeenCalled();
});
});
describe('getAvailableProviderInfos', () => {
it('should return an empty list when nothing is enabled', async () => {
repo.findEnabledProviders.mockResolvedValue([]);
+34 -10
View File
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { TtsCaptionedSpeech, TtsChapterText, TtsEffectivePreferences, TtsUserPreferences, TtsVoice } from '@bookorbit/types';
import type { TtsBookPreferences, TtsCaptionedSpeech, TtsChapterText, TtsEffectivePreferences, TtsUserPreferences, TtsVoice } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import type { StaticVoice } from '../../db/schema';
import { BookService } from '../book/book.service';
@@ -89,7 +89,11 @@ export class TtsService {
}
private async getVoicesForProvider(providerId: string): Promise<TtsVoice[]> {
const dbProvider = await this.ttsRepo.findProviderById(Number(providerId));
const numId = Number(providerId);
if (!Number.isSafeInteger(numId) || numId <= 0 || numId > 2147483647) {
throw new NotFoundException(`TTS provider ${providerId} not found or disabled`);
}
const dbProvider = await this.ttsRepo.findProviderById(numId);
if (!dbProvider || !dbProvider.enabled) throw new NotFoundException(`TTS provider ${providerId} not found or disabled`);
const provider = this.providerFactory.getOpenAiProvider(dbProvider);
return provider.listVoices();
@@ -128,10 +132,12 @@ export class TtsService {
}
async saveUserPreferences(userId: number, dto: UpdateTtsPreferencesDto): Promise<TtsUserPreferences> {
const providerId = dto.providerId !== undefined ? await this.providerIdForWrite(dto.providerId) : undefined;
const row = await this.ttsRepo.upsertUserPreferences(userId, {
...(dto.providerId !== undefined && { providerId: this.providerIdToInt(dto.providerId) }),
...(providerId !== undefined && { providerId }),
...(dto.voiceId !== undefined && { voiceId: dto.voiceId }),
...(dto.speed !== undefined && { speed: dto.speed }),
// The account's speed cannot be empty, so a null leaves it as it is.
...(dto.speed != null && { speed: dto.speed }),
});
return {
providerId: row.providerId != null ? String(row.providerId) : null,
@@ -167,13 +173,22 @@ export class TtsService {
};
}
async saveBookPreferences(userId: number, bookId: number, dto: UpdateTtsPreferencesDto, user: RequestUser) {
async saveBookPreferences(userId: number, bookId: number, dto: UpdateTtsPreferencesDto, user: RequestUser): Promise<TtsBookPreferences> {
await this.bookService.verifyBookAccess(bookId, user);
return this.ttsRepo.upsertBookPreferences(userId, bookId, {
...(dto.providerId !== undefined && { providerId: this.providerIdToInt(dto.providerId) }),
const providerId = dto.providerId !== undefined ? await this.providerIdForWrite(dto.providerId) : undefined;
const row = await this.ttsRepo.upsertBookPreferences(userId, bookId, {
...(providerId !== undefined && { providerId }),
...(dto.voiceId !== undefined && { voiceId: dto.voiceId }),
...(dto.speed !== undefined && { speed: dto.speed }),
});
// The documented shape, as the account's route answers, rather than the raw row with its
// numeric provider id.
return {
providerId: row.providerId != null ? String(row.providerId) : null,
voiceId: row.voiceId ?? null,
speed: row.speed ?? null,
bookId: row.bookId,
};
}
async deleteBookPreferences(userId: number, bookId: number, user: RequestUser) {
@@ -185,7 +200,8 @@ export class TtsService {
async getPosition(userId: number, bookFileId: number, user: RequestUser) {
await this.bookService.verifyFileAccess(bookFileId, user);
return this.ttsRepo.findPosition(userId, bookFileId);
// Nothing saved answers JSON null; an undefined result went out as an empty 200 body.
return (await this.ttsRepo.findPosition(userId, bookFileId)) ?? null;
}
async savePosition(userId: number, bookFileId: number, dto: SaveTtsPositionDto, user: RequestUser) {
@@ -277,9 +293,17 @@ export class TtsService {
return dbProvider ? curatedVoiceIds(dbProvider.staticVoices) : null;
}
private providerIdToInt(providerId: string | undefined): number | null {
/**
* The provider a preference write names. Empty or non-numeric names none, as before; a number
* must be a provider that exists, or the write was a database error rather than a 400.
*/
private async providerIdForWrite(providerId: string | null): Promise<number | null> {
if (!providerId) return null;
const n = Number(providerId);
return isNaN(n) ? null : n;
if (isNaN(n)) return null;
if (!Number.isSafeInteger(n) || n <= 0 || n > 2147483647 || !(await this.ttsRepo.findProviderById(n))) {
throw new BadRequestException(`Unknown TTS provider: ${providerId}`);
}
return n;
}
}