From 2f3b1bc7b7eb0cdf8704f53599d6620a7bb38a12 Mon Sep 17 00:00:00 2001 From: Neil Ruaro Date: Mon, 16 Mar 2026 22:36:12 +0800 Subject: [PATCH] Add CAMB AI TTS integration for ebook-to-audiobook synthesis --- package-lock.json | 39 ++++ package.json | 2 + scripts/test-camb-tts.js | 133 +++++++++++ server/controllers/TtsController.js | 95 ++++++++ server/managers/TtsManager.js | 129 +++++++++++ server/routers/ApiRouter.js | 7 + server/utils/textExtractor.js | 102 +++++++++ test/manual/TTS_TESTING.md | 73 ++++++ test/server/controllers/TtsController.test.js | 183 +++++++++++++++ test/server/managers/TtsManager.test.js | 212 ++++++++++++++++++ test/server/utils/textExtractor.test.js | 149 ++++++++++++ 11 files changed, 1124 insertions(+) create mode 100644 scripts/test-camb-tts.js create mode 100644 server/controllers/TtsController.js create mode 100644 server/managers/TtsManager.js create mode 100644 server/utils/textExtractor.js create mode 100644 test/manual/TTS_TESTING.md create mode 100644 test/server/controllers/TtsController.test.js create mode 100644 test/server/managers/TtsManager.test.js create mode 100644 test/server/utils/textExtractor.test.js diff --git a/package-lock.json b/package-lock.json index e07fba51d..ce7afdc1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.33.0", "license": "GPL-3.0", "dependencies": { + "@camb-ai/sdk": "^1.0.0", "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", @@ -17,6 +18,7 @@ "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", + "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", "nodemailer": "^6.9.13", "openid-client": "^5.6.1", @@ -509,6 +511,15 @@ "node": ">=6.9.0" } }, + "node_modules/@camb-ai/sdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@camb-ai/sdk/-/sdk-1.0.1.tgz", + "integrity": "sha512-mYKBfwiTZKPJruH1MJ/Fu7lOvbshZ2l3GmBuYLWebrq9AbArFhgIoIkmx6/JXrAMP0nTQyhbw/puo55nk9M4Ow==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -2113,6 +2124,21 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "devOptional": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3720,6 +3746,19 @@ "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "dev": true }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, "node_modules/node-unrar-js": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/node-unrar-js/-/node-unrar-js-2.0.2.tgz", diff --git a/package.json b/package.json index 3108b5170..62a07fbbe 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "author": "advplyr", "license": "GPL-3.0", "dependencies": { + "@camb-ai/sdk": "^1.0.0", "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", @@ -46,6 +47,7 @@ "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", + "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", "nodemailer": "^6.9.13", "openid-client": "^5.6.1", diff --git a/scripts/test-camb-tts.js b/scripts/test-camb-tts.js new file mode 100644 index 000000000..b37c10b8c --- /dev/null +++ b/scripts/test-camb-tts.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +/** + * Standalone integration test for CAMB AI TTS API. + * No audiobookshelf imports — uses native fetch only. + * + * Usage: + * CAMB_API_KEY=xxx node scripts/test-camb-tts.js + * node scripts/test-camb-tts.js --api-key=xxx + */ + +const fs = require('fs') +const os = require('os') +const path = require('path') + +const API_BASE = 'https://client.camb.ai/apis' + +function getApiKey() { + const cliArg = process.argv.find((a) => a.startsWith('--api-key=')) + if (cliArg) return cliArg.split('=')[1] + return process.env.CAMB_API_KEY +} + +let passed = 0 +let failed = 0 + +function pass(name) { + console.log(` [PASS] ${name}`) + passed++ +} + +function fail(name, reason) { + console.log(` [FAIL] ${name} — ${reason}`) + failed++ +} + +async function testListVoices(apiKey) { + console.log('\nTest 1: List voices') + try { + const res = await fetch(`${API_BASE}/list-voices`, { + headers: { 'x-api-key': apiKey } + }) + if (!res.ok) { + fail('list-voices', `HTTP ${res.status}`) + return + } + const voices = await res.json() + if (Array.isArray(voices) && voices.length > 0) { + const firstVoiceId = voices[0].id || voices[0].voice_id + pass(`list-voices (${voices.length} voices, using id=${firstVoiceId})`) + return firstVoiceId + } else { + fail('list-voices', `Unexpected response: ${JSON.stringify(voices).slice(0, 100)}`) + return null + } + } catch (err) { + fail('list-voices', err.message) + } +} + +async function testTtsStream(apiKey, voiceId) { + console.log(`\nTest 2: TTS stream (short text, voice_id=${voiceId})`) + try { + const res = await fetch(`${API_BASE}/tts-stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey + }, + body: JSON.stringify({ + text: 'Hello, this is a test of the CAMB AI text to speech system.', + voice_id: voiceId, + language: 'en-us', + speech_model: 'mars-flash', + output_configuration: { format: 'wav' } + }) + }) + + if (!res.ok) { + const body = await res.text() + fail('tts-stream', `HTTP ${res.status}: ${body.slice(0, 200)}`) + return + } + + const buf = Buffer.from(await res.arrayBuffer()) + if (buf.length < 100) { + fail('tts-stream', `Response too small (${buf.length} bytes)`) + return + } + + const tmpFile = path.join(os.tmpdir(), `camb-tts-test-${Date.now()}.wav`) + fs.writeFileSync(tmpFile, buf) + pass(`tts-stream (${buf.length} bytes → ${tmpFile})`) + } catch (err) { + fail('tts-stream', err.message) + } +} + +async function testInvalidKey() { + console.log('\nTest 3: Invalid API key') + try { + const res = await fetch(`${API_BASE}/list-voices`, { + headers: { 'x-api-key': 'invalid-key-12345' } + }) + if (res.ok) { + fail('invalid-key', 'Expected non-200 but got 200') + } else { + pass(`invalid-key (HTTP ${res.status})`) + } + } catch (err) { + fail('invalid-key', err.message) + } +} + +async function main() { + const apiKey = getApiKey() + if (!apiKey) { + console.error('Error: API key required. Set CAMB_API_KEY env var or use --api-key=KEY') + process.exit(1) + } + + console.log('CAMB AI TTS Integration Test') + console.log('============================') + + const voiceId = await testListVoices(apiKey) + await testTtsStream(apiKey, voiceId) + await testInvalidKey() + + console.log(`\nResults: ${passed} passed, ${failed} failed`) + process.exit(failed > 0 ? 1 : 0) +} + +main() diff --git a/server/controllers/TtsController.js b/server/controllers/TtsController.js new file mode 100644 index 000000000..d428a363a --- /dev/null +++ b/server/controllers/TtsController.js @@ -0,0 +1,95 @@ +const Logger = require('../Logger') + +class TtsController { + constructor() {} + + /** + * POST /api/tts/synthesize + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async synthesize(req, res) { + const { apiKey, libraryItemId, voiceId, language, model } = req.body + + if (!apiKey) { + return res.status(400).json({ error: 'API key is required' }) + } + if (!libraryItemId) { + return res.status(400).json({ error: 'libraryItemId is required' }) + } + if (!voiceId) { + return res.status(400).json({ error: 'voiceId is required' }) + } + + try { + // Find the library item and its ebook file + const Database = require('../Database') + const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId) + if (!libraryItem) { + return res.status(404).json({ error: 'Library item not found' }) + } + + // Find ebook file + const ebookFile = libraryItem.libraryFiles?.find((f) => f.metadata?.ext === '.epub') + if (!ebookFile) { + return res.status(400).json({ error: 'No EPUB file found for this library item' }) + } + + const Path = require('path') + const outputDir = Path.join(libraryItem.path, 'tts_output') + + const TtsManager = require('../managers/TtsManager') + const result = await TtsManager.synthesizeEbook({ + apiKey, + libraryItemId, + ebookPath: ebookFile.metadata.path, + outputDir, + voiceId: parseInt(voiceId), + language: language || 'en-us', + model: model || 'mars-flash' + }) + + res.json(result) + } catch (error) { + Logger.error(`[TtsController] Synthesis error: ${error.message}`) + res.status(500).json({ error: error.message }) + } + } + + /** + * GET /api/tts/voices + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getVoices(req, res) { + const { apiKey } = req.query + + if (!apiKey) { + return res.status(400).json({ error: 'API key is required' }) + } + + try { + const TtsManager = require('../managers/TtsManager') + const voices = await TtsManager.getVoices(apiKey) + res.json(voices) + } catch (error) { + Logger.error(`[TtsController] Get voices error: ${error.message}`) + res.status(500).json({ error: error.message }) + } + } + + /** + * Middleware for TTS routes + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {Function} next + */ + async middleware(req, res, next) { + if (!req.user.isAdminOrUp) { + return res.sendStatus(403) + } + next() + } +} + +module.exports = new TtsController() diff --git a/server/managers/TtsManager.js b/server/managers/TtsManager.js new file mode 100644 index 000000000..799380039 --- /dev/null +++ b/server/managers/TtsManager.js @@ -0,0 +1,129 @@ +const { CambClient } = require('@camb-ai/sdk') +const Path = require('path') +const fs = require('../libs/fsExtra') +const Logger = require('../Logger') +const TaskManager = require('./TaskManager') +const Task = require('../objects/Task') + +class TtsManager { + constructor() { + this.tasksRunning = [] + this.tasksQueued = [] + this.MAX_CONCURRENT = 1 + } + + /** + * Get list of available voices + * @param {string} apiKey + * @returns {Promise} + */ + async getVoices(apiKey) { + const client = new CambClient({ apiKey }) + const response = await fetch('https://client.camb.ai/apis/list-voices', { + headers: { 'x-api-key': apiKey } + }) + if (!response.ok) { + throw new Error(`Failed to list voices: ${response.status}`) + } + return response.json() + } + + /** + * Synthesize a chapter to audio + * @param {string} apiKey + * @param {string} text + * @param {number} voiceId + * @param {string} outputPath + * @param {string} language + * @param {string} model + * @returns {Promise} + */ + async synthesizeChapter(apiKey, text, voiceId, outputPath, language = 'en-us', model = 'mars-flash') { + const response = await fetch('https://client.camb.ai/apis/tts-stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey + }, + body: JSON.stringify({ + text, + voice_id: voiceId, + language, + speech_model: model, + output_configuration: { format: 'wav' } + }) + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`TTS API error (${response.status}): ${error}`) + } + + const arrayBuffer = await response.arrayBuffer() + await fs.writeFile(outputPath, Buffer.from(arrayBuffer)) + } + + /** + * Synthesize an ebook to audiobook + * @param {object} options + * @param {string} options.apiKey + * @param {string} options.libraryItemId + * @param {string} options.ebookPath + * @param {string} options.outputDir + * @param {number} options.voiceId + * @param {string} options.language + * @param {string} options.model + * @returns {Promise} + */ + async synthesizeEbook({ apiKey, libraryItemId, ebookPath, outputDir, voiceId, language, model }) { + const taskData = { + libraryItemId, + ebookPath, + outputDir + } + + const task = TaskManager.createAndAddTask('tts-synthesize', 'Synthesizing audiobook', 'Starting...', true, taskData) + + try { + const textExtractor = require('../utils/textExtractor') + const chapters = await textExtractor.extractFromEpub(ebookPath) + + if (!chapters.length) { + task.setFailed('No text content found in ebook') + TaskManager.taskFinished(task) + return { success: false, error: 'No text content found' } + } + + await fs.ensureDir(outputDir) + + const audioFiles = [] + for (let i = 0; i < chapters.length; i++) { + const chapter = chapters[i] + task.data.description = `Synthesizing chapter ${i + 1} of ${chapters.length}` + + const outputPath = Path.join(outputDir, `chapter_${String(i + 1).padStart(3, '0')}.wav`) + + await this.synthesizeChapter(apiKey, chapter.text, voiceId, outputPath, language, model) + audioFiles.push(outputPath) + + Logger.info(`[TtsManager] Synthesized chapter ${i + 1}/${chapters.length}`) + } + + task.setFinished(`Synthesized ${chapters.length} chapters`) + TaskManager.taskFinished(task) + + return { + success: true, + audioFiles, + chapters: chapters.map((c, i) => ({ title: c.title, audioFile: audioFiles[i] })) + } + } catch (error) { + Logger.error(`[TtsManager] Synthesis failed: ${error.message}`) + task.setFailed(error.message) + TaskManager.taskFinished(task) + return { success: false, error: error.message } + } + } +} + +module.exports = new TtsManager() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..587919dc1 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -33,6 +33,7 @@ const RSSFeedController = require('../controllers/RSSFeedController') const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController') const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') +const TtsController = require('../controllers/TtsController') const StatsController = require('../controllers/StatsController') const ApiKeyController = require('../controllers/ApiKeyController') @@ -335,6 +336,12 @@ class ApiRouter { this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this)) this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this)) + // + // TTS Routes (Admin and up) + // + this.router.post('/tts/synthesize', TtsController.middleware.bind(this), TtsController.synthesize.bind(this)) + this.router.get('/tts/voices', TtsController.middleware.bind(this), TtsController.getVoices.bind(this)) + // // Misc Routes // diff --git a/server/utils/textExtractor.js b/server/utils/textExtractor.js new file mode 100644 index 000000000..f5c47cc58 --- /dev/null +++ b/server/utils/textExtractor.js @@ -0,0 +1,102 @@ +const StreamZip = require('node-stream-zip') +const { xmlToJSON } = require('./index') +const Path = require('path') +const Logger = require('../Logger') + +class TextExtractor { + /** + * Extract text content from EPUB file, split by chapters + * @param {string} epubPath - Path to EPUB file + * @returns {Promise>} Array of chapters + */ + async extractFromEpub(epubPath) { + const zip = new StreamZip.async({ file: epubPath }) + + try { + // Read container.xml to find content.opf + const containerXml = await zip.entryData('META-INF/container.xml') + const container = await xmlToJSON(containerXml.toString()) + const rootfilePath = container?.container?.rootfiles?.[0]?.rootfile?.[0]?.$?.['full-path'] + + if (!rootfilePath) { + throw new Error('Could not find rootfile in container.xml') + } + + // Read content.opf + const opfData = await zip.entryData(rootfilePath) + const opf = await xmlToJSON(opfData.toString()) + const opfDir = Path.dirname(rootfilePath) + + // Get spine items (reading order) + const spine = opf?.package?.spine?.[0]?.itemref || [] + const manifest = opf?.package?.manifest?.[0]?.item || [] + + // Build manifest map + const manifestMap = {} + for (const item of manifest) { + const attrs = item.$ || item + if (attrs.id) { + manifestMap[attrs.id] = attrs + } + } + + const chapters = [] + + for (const itemref of spine) { + const idref = itemref.$?.idref || itemref.idref + const manifestItem = manifestMap[idref] + if (!manifestItem) continue + + const href = manifestItem.href + const mediaType = manifestItem['media-type'] + + // Only process HTML/XHTML content + if (!mediaType || (!mediaType.includes('html') && !mediaType.includes('xml'))) { + continue + } + + const filePath = opfDir ? Path.join(opfDir, href) : href + + try { + const content = await zip.entryData(filePath.replace(/\\/g, '/')) + const text = this.stripHtml(content.toString()) + + if (text.trim().length > 0) { + chapters.push({ + title: `Chapter ${chapters.length + 1}`, + text: text.trim() + }) + } + } catch (err) { + Logger.warn(`[TextExtractor] Could not read ${filePath}: ${err.message}`) + } + } + + return chapters + } finally { + await zip.close() + } + } + + /** + * Strip HTML tags and decode entities + * @param {string} html + * @returns {string} + */ + stripHtml(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim() + } +} + +module.exports = new TextExtractor() diff --git a/test/manual/TTS_TESTING.md b/test/manual/TTS_TESTING.md new file mode 100644 index 000000000..a2fd42b94 --- /dev/null +++ b/test/manual/TTS_TESTING.md @@ -0,0 +1,73 @@ +# TTS Integration Manual Testing Guide + +## Prerequisites + +- A running audiobookshelf instance (dev mode) +- A CAMB AI API key (from https://studio.camb.ai) +- At least one EPUB file imported into a library + +## Start Dev Server + +```bash +npm run dev +``` + +Server runs on port 3333 by default. + +## Get Auth Token + +Either extract from the UI (Settings > Users > API token) or login via API: + +```bash +curl -X POST http://localhost:3333/login \ + -H "Content-Type: application/json" \ + -d '{"username": "root", "password": "YOUR_PASSWORD"}' +``` + +Save the `token` from the response: + +```bash +export TOKEN="your-token-here" +export CAMB_API_KEY="your-camb-api-key" +``` + +## API Endpoints + +### GET /api/tts/voices + +List available CAMB AI voices. + +```bash +curl -s "http://localhost:3333/api/tts/voices?apiKey=$CAMB_API_KEY" \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +### POST /api/tts/synthesize + +Synthesize an ebook to audio. Find your `libraryItemId` from the UI URL or the library items API. + +```bash +curl -s -X POST http://localhost:3333/api/tts/synthesize \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "apiKey": "'$CAMB_API_KEY'", + "libraryItemId": "YOUR_LIBRARY_ITEM_ID", + "voiceId": "1", + "language": "en-us", + "model": "mars-flash" + }' | jq . +``` + +## Error Cases to Verify + +| Test Case | Expected | +|-----------|----------| +| No `Authorization` header | 401 Unauthorized | +| Non-admin user | 403 Forbidden | +| Missing `apiKey` in body/query | 400 `API key is required` | +| Missing `libraryItemId` | 400 `libraryItemId is required` | +| Missing `voiceId` | 400 `voiceId is required` | +| Non-existent library item ID | 404 `Library item not found` | +| Library item without EPUB | 400 `No EPUB file found for this library item` | +| Invalid CAMB AI API key | 500 with error from CAMB API | diff --git a/test/server/controllers/TtsController.test.js b/test/server/controllers/TtsController.test.js new file mode 100644 index 000000000..3e51839eb --- /dev/null +++ b/test/server/controllers/TtsController.test.js @@ -0,0 +1,183 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const TtsController = require('../../../server/controllers/TtsController') +const Logger = require('../../../server/Logger') + +describe('TtsController', () => { + let fakeRes + + beforeEach(() => { + fakeRes = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + json: sinon.spy() + } + + sinon.stub(Logger, 'error') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('middleware', () => { + it('should return 403 for non-admin users', async () => { + const fakeReq = { user: { isAdminOrUp: false } } + const next = sinon.spy() + + await TtsController.middleware(fakeReq, fakeRes, next) + + expect(fakeRes.sendStatus.calledWith(403)).to.be.true + expect(next.called).to.be.false + }) + + it('should call next() for admin users', async () => { + const fakeReq = { user: { isAdminOrUp: true } } + const next = sinon.spy() + + await TtsController.middleware(fakeReq, fakeRes, next) + + expect(fakeRes.sendStatus.called).to.be.false + expect(next.calledOnce).to.be.true + }) + }) + + describe('synthesize', () => { + it('should return 400 if apiKey is missing', async () => { + const fakeReq = { body: { libraryItemId: 'li1', voiceId: '1' } } + + await TtsController.synthesize(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.have.property('error', 'API key is required') + }) + + it('should return 400 if libraryItemId is missing', async () => { + const fakeReq = { body: { apiKey: 'key', voiceId: '1' } } + + await TtsController.synthesize(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.have.property('error', 'libraryItemId is required') + }) + + it('should return 400 if voiceId is missing', async () => { + const fakeReq = { body: { apiKey: 'key', libraryItemId: 'li1' } } + + await TtsController.synthesize(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.have.property('error', 'voiceId is required') + }) + + it('should return 404 if library item not found', async () => { + // Database.libraryItemModel is a getter from Database.models, so stub the getter + sinon.stub(Database, 'libraryItemModel').get(() => ({ + getExpandedById: sinon.stub().resolves(null) + })) + + const fakeReq = { body: { apiKey: 'key', libraryItemId: 'li1', voiceId: '1' } } + + await TtsController.synthesize(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(404)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.have.property('error', 'Library item not found') + }) + + it('should return 400 if no EPUB file found', async () => { + sinon.stub(Database, 'libraryItemModel').get(() => ({ + getExpandedById: sinon.stub().resolves({ + libraryFiles: [{ metadata: { ext: '.pdf' } }], + path: '/books/test' + }) + })) + + const fakeReq = { body: { apiKey: 'key', libraryItemId: 'li1', voiceId: '1' } } + + await TtsController.synthesize(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.have.property('error', 'No EPUB file found for this library item') + }) + + it('should call TtsManager.synthesizeEbook with correct args on success', async () => { + sinon.stub(Database, 'libraryItemModel').get(() => ({ + getExpandedById: sinon.stub().resolves({ + libraryFiles: [{ metadata: { ext: '.epub', path: '/books/test/book.epub' } }], + path: '/books/test' + }) + })) + + const TtsManager = require('../../../server/managers/TtsManager') + const expectedResult = { success: true, audioFiles: ['/out/ch1.wav'] } + sinon.stub(TtsManager, 'synthesizeEbook').resolves(expectedResult) + + const fakeReq = { + body: { apiKey: 'key', libraryItemId: 'li1', voiceId: '42' } + } + + await TtsController.synthesize(fakeReq, fakeRes) + + expect(TtsManager.synthesizeEbook.calledOnce).to.be.true + const callArgs = TtsManager.synthesizeEbook.firstCall.args[0] + expect(callArgs.apiKey).to.equal('key') + expect(callArgs.libraryItemId).to.equal('li1') + expect(callArgs.voiceId).to.equal(42) // parseInt + expect(callArgs.language).to.equal('en-us') // default + expect(callArgs.model).to.equal('mars-flash') // default + expect(callArgs.ebookPath).to.equal('/books/test/book.epub') + expect(fakeRes.json.calledWith(expectedResult)).to.be.true + }) + + it('should return 500 on thrown error', async () => { + sinon.stub(Database, 'libraryItemModel').get(() => ({ + getExpandedById: sinon.stub().rejects(new Error('DB error')) + })) + + const fakeReq = { body: { apiKey: 'key', libraryItemId: 'li1', voiceId: '1' } } + + await TtsController.synthesize(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(500)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.have.property('error', 'DB error') + }) + }) + + describe('getVoices', () => { + it('should return 400 if apiKey is missing', async () => { + const fakeReq = { query: {} } + + await TtsController.getVoices(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.have.property('error', 'API key is required') + }) + + it('should return voices on success', async () => { + const TtsManager = require('../../../server/managers/TtsManager') + const mockVoices = [{ id: 1, name: 'Voice 1' }] + sinon.stub(TtsManager, 'getVoices').resolves(mockVoices) + + const fakeReq = { query: { apiKey: 'test-key' } } + + await TtsController.getVoices(fakeReq, fakeRes) + + expect(TtsManager.getVoices.calledWith('test-key')).to.be.true + expect(fakeRes.json.calledWith(mockVoices)).to.be.true + }) + + it('should return 500 on thrown error', async () => { + const TtsManager = require('../../../server/managers/TtsManager') + sinon.stub(TtsManager, 'getVoices').rejects(new Error('API error')) + + const fakeReq = { query: { apiKey: 'test-key' } } + + await TtsController.getVoices(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(500)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.have.property('error', 'API error') + }) + }) +}) diff --git a/test/server/managers/TtsManager.test.js b/test/server/managers/TtsManager.test.js new file mode 100644 index 000000000..cd2e12ae5 --- /dev/null +++ b/test/server/managers/TtsManager.test.js @@ -0,0 +1,212 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const Path = require('path') + +describe('TtsManager', () => { + let TtsManager + let fsStub + let fetchStub + + beforeEach(() => { + // Stub @camb-ai/sdk before requiring TtsManager + require.cache[require.resolve('@camb-ai/sdk')] = { + id: require.resolve('@camb-ai/sdk'), + filename: require.resolve('@camb-ai/sdk'), + loaded: true, + exports: { CambClient: class MockCambClient {} } + } + + // Stub fs methods + fsStub = { + writeFile: sinon.stub().resolves(), + ensureDir: sinon.stub().resolves() + } + const fsPath = require.resolve('../../../server/libs/fsExtra') + require.cache[fsPath] = { + id: fsPath, + filename: fsPath, + loaded: true, + exports: fsStub + } + + // Stub global.fetch + fetchStub = sinon.stub(global, 'fetch') + + // Clear TtsManager cache and require fresh + delete require.cache[require.resolve('../../../server/managers/TtsManager')] + TtsManager = require('../../../server/managers/TtsManager') + }) + + afterEach(() => { + sinon.restore() + // Clean up require cache overrides + delete require.cache[require.resolve('@camb-ai/sdk')] + delete require.cache[require.resolve('../../../server/libs/fsExtra')] + delete require.cache[require.resolve('../../../server/managers/TtsManager')] + }) + + describe('getVoices', () => { + it('should call fetch with correct URL and headers', async () => { + const mockVoices = [{ id: 1, name: 'Voice 1' }] + fetchStub.resolves({ + ok: true, + json: sinon.stub().resolves(mockVoices) + }) + + const result = await TtsManager.getVoices('test-api-key') + + expect(fetchStub.calledOnce).to.be.true + const [url, options] = fetchStub.firstCall.args + expect(url).to.equal('https://client.camb.ai/apis/list-voices') + expect(options.headers['x-api-key']).to.equal('test-api-key') + expect(result).to.deep.equal(mockVoices) + }) + + it('should throw on non-ok response', async () => { + fetchStub.resolves({ ok: false, status: 401 }) + + try { + await TtsManager.getVoices('bad-key') + expect.fail('Should have thrown') + } catch (err) { + expect(err.message).to.include('Failed to list voices') + expect(err.message).to.include('401') + } + }) + }) + + describe('synthesizeChapter', () => { + it('should POST with correct body and write file', async () => { + const audioData = new ArrayBuffer(100) + fetchStub.resolves({ + ok: true, + arrayBuffer: sinon.stub().resolves(audioData) + }) + + await TtsManager.synthesizeChapter('key', 'Hello world', 5, '/out/ch1.wav', 'en-us', 'mars-flash') + + expect(fetchStub.calledOnce).to.be.true + const [url, options] = fetchStub.firstCall.args + expect(url).to.equal('https://client.camb.ai/apis/tts-stream') + expect(options.method).to.equal('POST') + expect(options.headers['x-api-key']).to.equal('key') + + const body = JSON.parse(options.body) + expect(body.text).to.equal('Hello world') + expect(body.voice_id).to.equal(5) + expect(body.language).to.equal('en-us') + expect(body.speech_model).to.equal('mars-flash') + expect(body.output_configuration).to.deep.equal({ format: 'wav' }) + + expect(fsStub.writeFile.calledOnce).to.be.true + expect(fsStub.writeFile.firstCall.args[0]).to.equal('/out/ch1.wav') + }) + + it('should throw on API error', async () => { + fetchStub.resolves({ + ok: false, + status: 500, + text: sinon.stub().resolves('Internal error') + }) + + try { + await TtsManager.synthesizeChapter('key', 'text', 1, '/out/ch1.wav') + expect.fail('Should have thrown') + } catch (err) { + expect(err.message).to.include('TTS API error') + expect(err.message).to.include('500') + } + }) + }) + + describe('synthesizeEbook', () => { + let mockTask + let TaskManager + let textExtractor + let Logger + + beforeEach(() => { + mockTask = { + data: {}, + setFailed: sinon.spy(), + setFinished: sinon.spy() + } + + TaskManager = require('../../../server/managers/TaskManager') + sinon.stub(TaskManager, 'createAndAddTask').returns(mockTask) + sinon.stub(TaskManager, 'taskFinished') + + // Stub textExtractor + textExtractor = require('../../../server/utils/textExtractor') + sinon.stub(textExtractor, 'extractFromEpub') + + Logger = require('../../../server/Logger') + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'error') + }) + + it('should return failure for empty chapters', async () => { + textExtractor.extractFromEpub.resolves([]) + + const result = await TtsManager.synthesizeEbook({ + apiKey: 'key', + libraryItemId: 'li1', + ebookPath: '/book.epub', + outputDir: '/out', + voiceId: 1, + language: 'en-us', + model: 'mars-flash' + }) + + expect(result.success).to.be.false + expect(mockTask.setFailed.calledOnce).to.be.true + expect(TaskManager.taskFinished.calledWith(mockTask)).to.be.true + }) + + it('should synthesize all chapters and return success', async () => { + textExtractor.extractFromEpub.resolves([ + { title: 'Chapter 1', text: 'Text one' }, + { title: 'Chapter 2', text: 'Text two' } + ]) + + // Stub synthesizeChapter on the instance + sinon.stub(TtsManager, 'synthesizeChapter').resolves() + + const result = await TtsManager.synthesizeEbook({ + apiKey: 'key', + libraryItemId: 'li1', + ebookPath: '/book.epub', + outputDir: '/out', + voiceId: 1, + language: 'en-us', + model: 'mars-flash' + }) + + expect(result.success).to.be.true + expect(TtsManager.synthesizeChapter.callCount).to.equal(2) + expect(result.audioFiles).to.have.length(2) + expect(result.chapters).to.have.length(2) + expect(result.chapters[0].title).to.equal('Chapter 1') + expect(mockTask.setFinished.calledOnce).to.be.true + }) + + it('should return failure on error', async () => { + textExtractor.extractFromEpub.rejects(new Error('Parse error')) + + const result = await TtsManager.synthesizeEbook({ + apiKey: 'key', + libraryItemId: 'li1', + ebookPath: '/book.epub', + outputDir: '/out', + voiceId: 1, + language: 'en-us', + model: 'mars-flash' + }) + + expect(result.success).to.be.false + expect(result.error).to.equal('Parse error') + expect(mockTask.setFailed.calledOnce).to.be.true + expect(TaskManager.taskFinished.calledWith(mockTask)).to.be.true + }) + }) +}) diff --git a/test/server/utils/textExtractor.test.js b/test/server/utils/textExtractor.test.js new file mode 100644 index 000000000..68c76b2db --- /dev/null +++ b/test/server/utils/textExtractor.test.js @@ -0,0 +1,149 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const Path = require('path') +const os = require('os') +const fs = require('fs') +const { execSync } = require('child_process') + +const textExtractor = require('../../../server/utils/textExtractor') + +describe('TextExtractor', () => { + describe('stripHtml', () => { + it('should strip HTML tags', () => { + const result = textExtractor.stripHtml('

Hello world

') + expect(result).to.equal('Hello world') + }) + + it('should remove script tags with content', () => { + const result = textExtractor.stripHtml('

Hello

World

') + expect(result).to.equal('Hello World') + }) + + it('should remove style tags with content', () => { + const result = textExtractor.stripHtml('

Text

') + expect(result).to.equal('Text') + }) + + it('should decode HTML entities', () => { + const result = textExtractor.stripHtml('& < > " '  ') + expect(result).to.equal("& < > \" '") + }) + + it('should collapse whitespace', () => { + const result = textExtractor.stripHtml('

Hello

World

\n\t

!

') + expect(result).to.equal('Hello World !') + }) + + it('should return empty string for empty input', () => { + const result = textExtractor.stripHtml('') + expect(result).to.equal('') + }) + }) + + describe('extractFromEpub', () => { + let epubPath + let tmpDir + + /** + * Creates a minimal valid EPUB file using the system zip command + */ + function createTestEpub(destPath) { + tmpDir = fs.mkdtempSync(Path.join(os.tmpdir(), 'epub-test-')) + + // Create directory structure + fs.mkdirSync(Path.join(tmpDir, 'META-INF'), { recursive: true }) + fs.mkdirSync(Path.join(tmpDir, 'OEBPS'), { recursive: true }) + + // mimetype + fs.writeFileSync(Path.join(tmpDir, 'mimetype'), 'application/epub+zip') + + // container.xml + fs.writeFileSync( + Path.join(tmpDir, 'META-INF', 'container.xml'), + ` + + + + +` + ) + + // content.opf + fs.writeFileSync( + Path.join(tmpDir, 'OEBPS', 'content.opf'), + ` + + + Test Book + + + + + + + + + +` + ) + + // chapter1.xhtml + fs.writeFileSync( + Path.join(tmpDir, 'OEBPS', 'chapter1.xhtml'), + ` + +Chapter 1 +

Chapter One

This is the first chapter content.

+` + ) + + // chapter2.xhtml + fs.writeFileSync( + Path.join(tmpDir, 'OEBPS', 'chapter2.xhtml'), + ` + +Chapter 2 +

Chapter Two

This is the second chapter.

+` + ) + + // Create ZIP (EPUB is a ZIP file) + // First add mimetype uncompressed, then the rest + execSync(`cd "${tmpDir}" && zip -0 -X "${destPath}" mimetype && zip -r -X "${destPath}" META-INF OEBPS`) + } + + before(() => { + epubPath = Path.join(os.tmpdir(), `test-epub-${Date.now()}.epub`) + createTestEpub(epubPath) + }) + + after(() => { + try { + fs.unlinkSync(epubPath) + } catch (_) {} + try { + fs.rmSync(tmpDir, { recursive: true }) + } catch (_) {} + }) + + it('should extract chapters from a valid EPUB', async () => { + const chapters = await textExtractor.extractFromEpub(epubPath) + + expect(chapters).to.be.an('array') + expect(chapters.length).to.equal(2) + expect(chapters[0].title).to.equal('Chapter 1') + expect(chapters[0].text).to.include('first chapter content') + expect(chapters[1].title).to.equal('Chapter 2') + expect(chapters[1].text).to.include('second chapter') + }) + + it('should throw for non-existent file', async () => { + try { + await textExtractor.extractFromEpub('/nonexistent/path/book.epub') + expect.fail('Should have thrown') + } catch (err) { + expect(err).to.be.an('error') + } + }) + }) +})