Add "Download by Chapters" tool for audiobooks

Adds a new tool in the item tools tab that splits a book's audio file
into per-chapter files using ffmpeg -c copy (no re-encoding) and
packages them as a ZIP download.

Key details:
- New GET /api/items/:id/download-chapters endpoint
- Accepts optional ?fileIno= to select which audio file to split when
  a book has multiple (e.g. m4b + mp3)
- Computes each file's start offset in the global chapter timeline and
  filters + re-bases chapter timestamps to be file-relative before
  splitting, handling the case where ABS stores chapters from all audio
  files with concatenated global timestamps
- Splits all chapters in parallel (ffmpeg -c copy per chapter) then
  streams the result as a ZIP
- Temp files written to MetadataPath/cache/items/:id/chapter-split-:ts
  with a timestamp suffix to prevent concurrent request collisions;
  cleaned up in a finally block regardless of success or failure
- UI shows a file selector dropdown when multiple audio files exist,
  with "Preparing..." state feedback during the request
- 10 new tests covering guard conditions and the chapter offset logic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Victor Da Luz 2026-04-14 17:24:01 -06:00
parent 88667d00a1
commit 640c2f98b8
5 changed files with 347 additions and 1 deletions

View file

@ -48,6 +48,26 @@
</widgets-alert>
</div>
<!-- Download by chapters -->
<div v-if="showChapterDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-start">
<div>
<p class="text-lg">{{ $strings.LabelToolsDownloadByChapters }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsDownloadByChaptersDescription }}</p>
</div>
<div class="grow" />
<div class="flex flex-col items-end gap-2 mt-2">
<select v-if="audioFiles.length > 1" v-model="selectedFileIno" class="bg-primary border border-gray-600 rounded px-2 py-1 text-sm w-full max-w-xs truncate">
<option v-for="af in audioFiles" :key="af.ino" :value="af.ino">{{ af.metadata.filename }}</option>
</select>
<ui-btn :disabled="isChapterDownloading" @click.stop="downloadChapters">
<span v-if="isChapterDownloading">{{ $strings.ButtonChapterDownloadZipPreparing }}</span>
<span v-else>{{ $strings.ButtonChapterDownloadZip }}</span>
</ui-btn>
</div>
</div>
</div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
</div>
</template>
@ -62,7 +82,10 @@ export default {
}
},
data() {
return {}
return {
selectedFileIno: null,
isChapterDownloading: false
}
},
computed: {
libraryItemId() {
@ -77,6 +100,18 @@ export default {
chapters() {
return this.media.chapters || []
},
audioFiles() {
return this.media.audioFiles?.filter((af) => !af.exclude) || []
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userToken() {
return this.$store.getters['user/getToken']
},
showChapterDownload() {
return this.chapters.length > 0 && this.userCanDownload && this.audioFiles.length > 0
},
showM4bDownload() {
if (!this.mediaTracks.length) return false
return true
@ -103,7 +138,37 @@ export default {
return this.encodeTask && !this.encodeTask?.isFinished
}
},
watch: {
audioFiles: {
immediate: true,
handler(files) {
if (files.length && !this.selectedFileIno) {
this.selectedFileIno = files[0].ino
}
}
}
},
methods: {
downloadChapters() {
const ino = this.audioFiles.length === 1 ? this.audioFiles[0].ino : this.selectedFileIno
const params = new URLSearchParams({ token: this.userToken })
if (ino) params.append('fileIno', ino)
const url = `${process.env.serverUrl}/api/items/${this.libraryItemId}/download-chapters?${params}`
this.isChapterDownloading = true
// $nextTick ensures the DOM is updated, then requestAnimationFrame ensures
// the browser has actually painted the frame before the anchor click runs.
// Without this, the click blocks the JS thread before "Preparing..." is visible.
this.$nextTick(() => {
requestAnimationFrame(() => {
this.$downloadFile(url, `${this.media.title} - Chapters.zip`)
// $downloadFile has no completion callback reset after a delay.
setTimeout(() => {
this.isChapterDownloading = false
}, 3000)
})
})
},
quickEmbed() {
const payload = {
message: this.$strings.MessageConfirmQuickEmbed,

View file

@ -70,6 +70,8 @@
"ButtonPurgeItemsCache": "Purge Items Cache",
"ButtonQueueAddItem": "Add to queue",
"ButtonQueueRemoveItem": "Remove from queue",
"ButtonChapterDownloadZip": "Download ZIP",
"ButtonChapterDownloadZipPreparing": "Preparing...",
"ButtonQuickEmbed": "Quick Embed",
"ButtonQuickEmbedMetadata": "Quick Embed Metadata",
"ButtonQuickMatch": "Quick Match",
@ -685,6 +687,8 @@
"LabelToolsM4bEncoder": "M4B Encoder",
"LabelToolsMakeM4b": "Make M4B Audiobook File",
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsDownloadByChapters": "Download by Chapters",
"LabelToolsDownloadByChaptersDescription": "Download this audiobook as separate audio files, one per chapter, packaged in a ZIP.",
"LabelToolsSplitM4b": "Split M4B to MP3's",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
"LabelTotalDuration": "Total Duration",

View file

@ -8,6 +8,8 @@ const Database = require('../Database')
const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp } = require('../utils/index')
const Ffmpeg = require('../libs/fluentFfmpeg')
const archiver = require('../libs/archiver')
const { ScanResult, AudioMimeType } = require('../utils/constants')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
@ -140,6 +142,37 @@ class LibraryItemController {
}
}
/**
* Filter book chapters to those belonging to a specific audio file and convert
* their timestamps to be relative to that file's start.
*
* ABS stores chapters with global timestamps spanning all audio files in sequence.
* Given a selected audio file, this computes its start offset (sum of durations of
* all files ordered before it), filters to chapters that start within that range,
* and shifts timestamps to be file-relative (starting at 0).
*
* @param {object[]} allChapters - All chapters from the book, with global timestamps
* @param {object} audioFile - The selected audio file object (must have ino, index, duration)
* @param {object[]} includedAudioFiles - All non-excluded audio files for the book
* @returns {object[]} Chapters filtered to the selected file with file-relative timestamps
*/
static getChaptersForAudioFile(allChapters, audioFile, includedAudioFiles) {
const sortedFiles = [...includedAudioFiles].sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
let fileStartOffset = 0
for (const af of sortedFiles) {
if (af.ino === audioFile.ino) break
fileStartOffset += af.duration || 0
}
const fileEndOffset = fileStartOffset + (audioFile.duration || 0)
return allChapters
.filter((ch) => ch.start >= fileStartOffset && ch.start < fileEndOffset)
.map((ch) => ({
...ch,
start: ch.start - fileStartOffset,
end: Math.min(ch.end, fileEndOffset) - fileStartOffset
}))
}
/**
* GET: /api/items/:id/download
* Download library item. Zip file if multiple files.
@ -1055,6 +1088,125 @@ class LibraryItemController {
}
}
/**
* GET: /api/items/:id/download-chapters
* Split an audiobook into per-chapter files and download as a ZIP.
* Query params:
* fileIno - inode of the audio file to split (defaults to first included audio file)
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async downloadChapterFiles(req, res) {
if (!req.user.canDownload) {
Logger.warn(`User "${req.user.username}" attempted to download without permission`)
return res.sendStatus(403)
}
if (!req.libraryItem.isBook) {
return res.status(400).send('Chapter download is only supported for books')
}
const allChapters = req.libraryItem.media.chapters
if (!allChapters?.length) {
return res.status(400).send('No chapters found for this item')
}
// Find the target audio file by inode, or default to first included audio file
const fileIno = req.query.fileIno
const includedAudioFiles = req.libraryItem.media.audioFiles?.filter((af) => !af.exclude) || []
let audioFile
if (fileIno) {
audioFile = includedAudioFiles.find((af) => af.ino === fileIno)
if (!audioFile) {
return res.status(400).send('Audio file not found')
}
} else {
audioFile = includedAudioFiles[0]
if (!audioFile) {
return res.status(400).send('No audio files found for this item')
}
}
// Compute start offset of the selected file within the combined book timeline.
// ABS stores chapters across all audio files with global timestamps — we need
// to filter to only chapters belonging to this file and convert to file-relative times.
const chapters = LibraryItemController.getChaptersForAudioFile(allChapters, audioFile, includedAudioFiles)
const inputPath = audioFile.metadata.path
const inputExt = Path.extname(audioFile.metadata.filename)
const bookTitle = req.libraryItem.media.title
const safeBookTitle = bookTitle.replace(/[/\\:*?"<>|]/g, '_').trim()
// Unique dir per request — prevents leftover files from concurrent or prior requests
const itemCacheDir = Path.join(global.MetadataPath, 'cache', 'items', req.libraryItem.id, `chapter-split-${Date.now()}`)
Logger.info(`[LibraryItemController] User "${req.user.username}" requested chapter download for "${bookTitle}" (${chapters.length} chapters)`)
try {
await fs.ensureDir(itemCacheDir)
const padLength = chapters.length.toString().length
// Split all chapters in parallel — all reads from the same input, different output paths
await Promise.all(
chapters.map(async (chapter, i) => {
const duration = chapter.end - chapter.start
if (duration <= 0) return
const paddedNum = String(i + 1).padStart(padLength, '0')
const safeTitle = (chapter.title || `Chapter ${i + 1}`).replace(/[/\\:*?"<>|]/g, '_').trim()
const outputFilename = `${paddedNum} - ${safeTitle}${inputExt}`
const outputPath = Path.join(itemCacheDir, outputFilename)
await new Promise((resolve, reject) => {
Ffmpeg(inputPath)
.seekInput(chapter.start)
.duration(duration)
.outputOptions('-map 0:a')
.audioCodec('copy')
.output(outputPath)
.on('end', resolve)
.on('error', (err) => {
Logger.error(`[LibraryItemController] ffmpeg error splitting chapter "${chapter.title}": ${err.message}`)
reject(err)
})
.run()
})
})
)
// Stream zip to client
const zipFilename = `${safeBookTitle} - Chapters.zip`
res.attachment(zipFilename)
const archive = archiver('zip', { zlib: { level: 0 } })
await new Promise((resolve, reject) => {
res.on('close', resolve)
res.on('end', resolve)
archive.on('error', reject)
archive.on('warning', (err) => {
if (err.code !== 'ENOENT') reject(err)
else Logger.warn(`[LibraryItemController] Archiver warning: ${err.message}`)
})
archive.pipe(res)
archive.directory(itemCacheDir, false)
archive.finalize()
})
Logger.info(`[LibraryItemController] Chapter download complete for "${bookTitle}"`)
} catch (error) {
Logger.error(`[LibraryItemController] Chapter download failed for "${bookTitle}": ${error.message}`)
LibraryItemController.handleDownloadError(error, res)
} finally {
// Clean up temp files regardless of success or failure
fs.remove(itemCacheDir).catch((err) => {
Logger.error(`[LibraryItemController] Failed to clean up chapter split dir for "${bookTitle}": ${err.message}`)
})
}
}
/**
* GET api/items/:id/ebook/:fileid?
* fileid is the inode value stored in LibraryFile.ino or EBookFile.ino

View file

@ -108,6 +108,7 @@ class ApiRouter {
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
this.router.get('/items/:id/download-chapters', LibraryItemController.middleware.bind(this), LibraryItemController.downloadChapterFiles.bind(this))
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
this.router.get('/items/:id/cover', LibraryItemController.getCover.bind(this))
this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.uploadCover.bind(this))

View file

@ -34,6 +34,130 @@ describe('LibraryItemController', () => {
await Database.sequelize.sync({ force: true })
})
describe('downloadChapterFiles', () => {
const makeReq = (overrides = {}) => ({
user: { canDownload: true, username: 'testuser' },
libraryItem: {
isBook: true,
id: 'item-1',
media: {
title: 'Test Book',
chapters: [],
audioFiles: []
}
},
query: {},
...overrides
})
const makeRes = () => ({
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy()
})
it('should return 403 when user cannot download', async () => {
const req = makeReq({ user: { canDownload: false, username: 'testuser' } })
const res = makeRes()
await LibraryItemController.downloadChapterFiles.bind(apiRouter)(req, res)
expect(res.sendStatus.calledWith(403)).to.be.true
})
it('should return 400 when library item is not a book', async () => {
const req = makeReq({ libraryItem: { isBook: false, media: {} } })
const res = makeRes()
await LibraryItemController.downloadChapterFiles.bind(apiRouter)(req, res)
expect(res.status.calledWith(400)).to.be.true
})
it('should return 400 when book has no chapters', async () => {
const req = makeReq()
req.libraryItem.media.chapters = []
const res = makeRes()
await LibraryItemController.downloadChapterFiles.bind(apiRouter)(req, res)
expect(res.status.calledWith(400)).to.be.true
})
it('should return 400 when specified fileIno is not found', async () => {
const req = makeReq()
req.libraryItem.media.chapters = [{ id: 0, start: 0, end: 100, title: 'Ch 1' }]
req.libraryItem.media.audioFiles = [{ ino: 'abc', exclude: false, index: 1, duration: 100, metadata: { filename: 'book.mp3', path: '/book.mp3' } }]
req.query = { fileIno: 'nonexistent' }
const res = makeRes()
await LibraryItemController.downloadChapterFiles.bind(apiRouter)(req, res)
expect(res.status.calledWith(400)).to.be.true
})
})
describe('getChaptersForAudioFile', () => {
const file1 = { ino: 'f1', index: 1, duration: 1000 }
const file2 = { ino: 'f2', index: 2, duration: 800 }
it('returns all chapters unchanged for a single audio file', () => {
const chapters = [
{ id: 0, start: 0, end: 300, title: 'Intro' },
{ id: 1, start: 300, end: 700, title: 'Part 1' },
{ id: 2, start: 700, end: 1000, title: 'Part 2' }
]
const result = LibraryItemController.constructor.getChaptersForAudioFile(chapters, file1, [file1])
expect(result).to.have.length(3)
expect(result[0].start).to.equal(0)
expect(result[2].end).to.equal(1000)
})
it('filters chapters to the selected file when multiple files exist', () => {
// file1: 01000s, file2: 10001800s (global)
const chapters = [
{ id: 0, start: 0, end: 500, title: 'A' },
{ id: 1, start: 500, end: 1000, title: 'B' },
{ id: 2, start: 1000, end: 1400, title: 'C' },
{ id: 3, start: 1400, end: 1800, title: 'D' }
]
const result = LibraryItemController.constructor.getChaptersForAudioFile(chapters, file2, [file1, file2])
expect(result).to.have.length(2)
expect(result[0].title).to.equal('C')
expect(result[1].title).to.equal('D')
})
it('converts global timestamps to file-relative timestamps', () => {
// file1: 01000s, file2: 10001800s (global)
const chapters = [
{ id: 2, start: 1000, end: 1400, title: 'C' },
{ id: 3, start: 1400, end: 1800, title: 'D' }
]
const result = LibraryItemController.constructor.getChaptersForAudioFile(chapters, file2, [file1, file2])
expect(result[0].start).to.equal(0)
expect(result[0].end).to.equal(400)
expect(result[1].start).to.equal(400)
expect(result[1].end).to.equal(800)
})
it('clamps the last chapter end to the file duration', () => {
// Global chapter end overshoots the file boundary
const chapters = [{ id: 0, start: 1000, end: 9999, title: 'Last' }]
const result = LibraryItemController.constructor.getChaptersForAudioFile(chapters, file2, [file1, file2])
expect(result[0].end).to.equal(800) // file2 duration
})
it('excludes chapters that start outside the selected file range', () => {
const chapters = [
{ id: 0, start: 0, end: 1000, title: 'File1 chapter' },
{ id: 1, start: 1000, end: 1800, title: 'File2 chapter' }
]
const result = LibraryItemController.constructor.getChaptersForAudioFile(chapters, file1, [file1, file2])
expect(result).to.have.length(1)
expect(result[0].title).to.equal('File1 chapter')
})
it('sorts files by index regardless of input order', () => {
const unordered = [file2, file1] // file2 first in array, but index 2
const chapters = [{ id: 0, start: 0, end: 500, title: 'A' }]
const result = LibraryItemController.constructor.getChaptersForAudioFile(chapters, file1, unordered)
expect(result).to.have.length(1)
expect(result[0].start).to.equal(0)
})
})
describe('checkRemoveAuthorsAndSeries', () => {
let libraryItem1Id
let libraryItem2Id