mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
Add CAMB AI TTS integration for ebook-to-audiobook synthesis
This commit is contained in:
parent
6d3773a0b8
commit
2f3b1bc7b7
11 changed files with 1124 additions and 0 deletions
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
133
scripts/test-camb-tts.js
Normal 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()
|
||||
95
server/controllers/TtsController.js
Normal file
95
server/controllers/TtsController.js
Normal 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()
|
||||
129
server/managers/TtsManager.js
Normal file
129
server/managers/TtsManager.js
Normal 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()
|
||||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
102
server/utils/textExtractor.js
Normal file
102
server/utils/textExtractor.js
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TextExtractor()
|
||||
73
test/manual/TTS_TESTING.md
Normal file
73
test/manual/TTS_TESTING.md
Normal 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 |
|
||||
183
test/server/controllers/TtsController.test.js
Normal file
183
test/server/controllers/TtsController.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
212
test/server/managers/TtsManager.test.js
Normal file
212
test/server/managers/TtsManager.test.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
149
test/server/utils/textExtractor.test.js
Normal file
149
test/server/utils/textExtractor.test.js
Normal 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('& < > " ' ')
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue