|
|
@@ -0,0 +1,170 @@
|
|
|
+/**
|
|
|
+ * 章节统一朗读接口 — 给小智音箱/外部设备用
|
|
|
+ *
|
|
|
+ * GET /api/book-generator/books/chapters/:id/read?format=audio|text|auto[&voice_id=&speed=&offset=&max_chars=]
|
|
|
+ *
|
|
|
+ * 用途:把"返回音频 URL 让音箱直接播"和"返回文本让音箱自己 TTS 读"统一为一个端点,
|
|
|
+ * 由调用方通过 format 参数显式选择。
|
|
|
+ *
|
|
|
+ * 设计要点:
|
|
|
+ * - 独立端点,不动 album-controller.ts 的 getChapterDetail(避免破坏现有 caller)。
|
|
|
+ * - format=audio:无合法音频时触发 on-demand TTS;失败返 502 TTS_FALLBACK_FAILED。
|
|
|
+ * - format=text:stripMarkdown 后按 offset/max_chars 分页,纯本地处理。
|
|
|
+ * - format=auto(默认):有音频 → audio;无音频 → text(**不**走 on-demand,避免误合成)。
|
|
|
+ *
|
|
|
+ * 鉴权:optionalAuth + assertBookAccess('read'),与现有章节接口一致。
|
|
|
+ */
|
|
|
+import Router from '@koa/router';
|
|
|
+import type { Context } from 'koa';
|
|
|
+import { optionalAuth } from '../../middleware/auth';
|
|
|
+import { prisma } from '../../models';
|
|
|
+import { assertBookAccess } from './access-control';
|
|
|
+import { stripMarkdown } from '../tts/tts.service';
|
|
|
+import { onDemandChapterTts } from './chapter-tts';
|
|
|
+import { getChapterNav } from './chapter-nav';
|
|
|
+
|
|
|
+// 开发环境测试用户ID(与 album-controller 对齐)
|
|
|
+const TEST_USER_ID = '1';
|
|
|
+
|
|
|
+const DEFAULT_TEXT_MAX_CHARS = 800;
|
|
|
+const HARD_TEXT_MAX_CHARS = 4000;
|
|
|
+const VALID_FORMATS = ['audio', 'text', 'auto'] as const;
|
|
|
+type ReadFormat = typeof VALID_FORMATS[number];
|
|
|
+
|
|
|
+const router = new Router({ prefix: '/books/chapters' });
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /books/chapters/:id/read
|
|
|
+ *
|
|
|
+ * Query:
|
|
|
+ * format - audio | text | auto (default auto)
|
|
|
+ * voice_id - optional, on-demand TTS 音色
|
|
|
+ * speed - optional, 语速 0.5~2.0
|
|
|
+ * offset - optional, text 模式分页起点
|
|
|
+ * max_chars - optional, text 模式分页大小(默认 800,上限 4000)
|
|
|
+ */
|
|
|
+async function readChapter(ctx: Context): Promise<void> {
|
|
|
+ const chapterId = parseInt(ctx.params.id);
|
|
|
+ const userId = ctx.state.user?.userId || TEST_USER_ID;
|
|
|
+ const format = ((ctx.query.format as string) || 'auto') as ReadFormat;
|
|
|
+
|
|
|
+ if (!VALID_FORMATS.includes(format)) {
|
|
|
+ ctx.status = 400;
|
|
|
+ ctx.body = { code: 2004, message: `INVALID_FORMAT (must be one of ${VALID_FORMATS.join(', ')})`, data: null };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (Number.isNaN(chapterId)) {
|
|
|
+ ctx.status = 400;
|
|
|
+ ctx.body = { code: 2004, message: 'INVALID_CHAPTER_ID', data: null };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
|
|
|
+ if (!chapter || !(await assertBookAccess(chapter.bookId, userId, 'read'))) {
|
|
|
+ ctx.status = 404;
|
|
|
+ ctx.body = { code: 2001, message: 'CHAPTER_NOT_FOUND', data: null };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const nav = await getChapterNav(chapter.bookId, chapter.id);
|
|
|
+ const meta = {
|
|
|
+ chapter_id: chapter.id,
|
|
|
+ book_id: chapter.bookId,
|
|
|
+ title: chapter.title,
|
|
|
+ word_count: chapter.wordCount || 0,
|
|
|
+ nav,
|
|
|
+ };
|
|
|
+
|
|
|
+ const hasValidAudio = !!chapter.audioUrl && !chapter.audioUrl.startsWith('/uploads/');
|
|
|
+
|
|
|
+ // -------- format=text:纯文本分支(不调 TTS) --------
|
|
|
+ if (format === 'text') {
|
|
|
+ const maxChars = Math.min(parseInt(ctx.query.max_chars as string) || DEFAULT_TEXT_MAX_CHARS, HARD_TEXT_MAX_CHARS);
|
|
|
+ const offset = Math.max(parseInt(ctx.query.offset as string) || 0, 0);
|
|
|
+ const clean = stripMarkdown(chapter.content || '');
|
|
|
+ const text = clean.slice(offset, offset + maxChars);
|
|
|
+ const hasMore = offset + maxChars < clean.length;
|
|
|
+ ctx.body = {
|
|
|
+ code: 0,
|
|
|
+ message: 'success',
|
|
|
+ data: {
|
|
|
+ mode: 'text',
|
|
|
+ ...meta,
|
|
|
+ text,
|
|
|
+ has_more: hasMore,
|
|
|
+ next_offset: hasMore ? offset + maxChars : null,
|
|
|
+ total_chars: clean.length,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // -------- format=audio | auto:有合法音频直接返 --------
|
|
|
+ if (hasValidAudio) {
|
|
|
+ ctx.body = {
|
|
|
+ code: 0,
|
|
|
+ message: 'success',
|
|
|
+ data: {
|
|
|
+ mode: 'audio',
|
|
|
+ ...meta,
|
|
|
+ audio_url: chapter.audioUrl,
|
|
|
+ audio_duration: chapter.audioDuration || 0,
|
|
|
+ audio_source: chapter.audioSource || 'full',
|
|
|
+ },
|
|
|
+ };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // -------- 没音频:auto 走 text;audio 走 on-demand TTS --------
|
|
|
+ if (format === 'auto') {
|
|
|
+ const clean = stripMarkdown(chapter.content || '');
|
|
|
+ const maxChars = Math.min(parseInt(ctx.query.max_chars as string) || DEFAULT_TEXT_MAX_CHARS, HARD_TEXT_MAX_CHARS);
|
|
|
+ const offset = Math.max(parseInt(ctx.query.offset as string) || 0, 0);
|
|
|
+ const text = clean.slice(offset, offset + maxChars);
|
|
|
+ const hasMore = offset + maxChars < clean.length;
|
|
|
+ ctx.body = {
|
|
|
+ code: 0,
|
|
|
+ message: 'success',
|
|
|
+ data: {
|
|
|
+ mode: 'text',
|
|
|
+ ...meta,
|
|
|
+ text,
|
|
|
+ has_more: hasMore,
|
|
|
+ next_offset: hasMore ? offset + maxChars : null,
|
|
|
+ total_chars: clean.length,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // format === 'audio' 显式要音频 → on-demand TTS 兜底
|
|
|
+ try {
|
|
|
+ const speed = ctx.query.speed ? parseFloat(ctx.query.speed as string) : undefined;
|
|
|
+ const voiceId = ctx.query.voice_id as string | undefined;
|
|
|
+ const result = await onDemandChapterTts(chapter, {
|
|
|
+ voiceId,
|
|
|
+ speed: speed !== undefined && !Number.isNaN(speed) ? speed : undefined,
|
|
|
+ });
|
|
|
+ ctx.body = {
|
|
|
+ code: 0,
|
|
|
+ message: 'success',
|
|
|
+ data: {
|
|
|
+ mode: 'audio',
|
|
|
+ ...meta,
|
|
|
+ audio_url: result.audioUrl,
|
|
|
+ audio_duration: result.duration,
|
|
|
+ audio_source: 'on_demand',
|
|
|
+ },
|
|
|
+ };
|
|
|
+ } catch (e: any) {
|
|
|
+ console.error(`[readChapter] on-demand TTS failed for chapter ${chapter.id}:`, e?.message);
|
|
|
+ ctx.status = 502;
|
|
|
+ ctx.body = { code: 2002, message: `TTS_FALLBACK_FAILED: ${e?.message || 'unknown'}`, data: null };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+router.get('/:id/read', optionalAuth, readChapter);
|
|
|
+
|
|
|
+export { readChapter };
|
|
|
+
|
|
|
+export default router;
|