Add CAMB AI TTS integration for ebook-to-audiobook synthesis

This commit is contained in:
Neil Ruaro 2026-03-16 22:36:12 +08:00
parent 6d3773a0b8
commit 2f3b1bc7b7
11 changed files with 1124 additions and 0 deletions

39
package-lock.json generated
View file

@ -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",

View file

@ -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",

133
scripts/test-camb-tts.js Normal file
View file

@ -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()

View file

@ -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()

View file

@ -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<Array>}
*/
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<void>}
*/
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<object>}
*/
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()

View file

@ -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
//

View file

@ -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<{title: string, text: string}>>} 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(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
}
module.exports = new TextExtractor()

View file

@ -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 |

View file

@ -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')
})
})
})

View file

@ -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
})
})
})

View file

@ -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('<p>Hello <b>world</b></p>')
expect(result).to.equal('Hello world')
})
it('should remove script tags with content', () => {
const result = textExtractor.stripHtml('<p>Hello</p><script>alert("x")</script><p>World</p>')
expect(result).to.equal('Hello World')
})
it('should remove style tags with content', () => {
const result = textExtractor.stripHtml('<style>.foo { color: red; }</style><p>Text</p>')
expect(result).to.equal('Text')
})
it('should decode HTML entities', () => {
const result = textExtractor.stripHtml('&amp; &lt; &gt; &quot; &#39; &nbsp;')
expect(result).to.equal("& < > \" '")
})
it('should collapse whitespace', () => {
const result = textExtractor.stripHtml('<p>Hello</p> <p>World</p> \n\t <p>!</p>')
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'),
`<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`
)
// content.opf
fs.writeFileSync(
Path.join(tmpDir, 'OEBPS', 'content.opf'),
`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Test Book</dc:title>
</metadata>
<manifest>
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="ch2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine>
<itemref idref="ch1"/>
<itemref idref="ch2"/>
</spine>
</package>`
)
// chapter1.xhtml
fs.writeFileSync(
Path.join(tmpDir, 'OEBPS', 'chapter1.xhtml'),
`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Chapter 1</title></head>
<body><h1>Chapter One</h1><p>This is the first chapter content.</p></body>
</html>`
)
// chapter2.xhtml
fs.writeFileSync(
Path.join(tmpDir, 'OEBPS', 'chapter2.xhtml'),
`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Chapter 2</title></head>
<body><h1>Chapter Two</h1><p>This is the second chapter.</p></body>
</html>`
)
// 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')
}
})
})
})