mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
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:
parent
88667d00a1
commit
640c2f98b8
5 changed files with 347 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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: 0–1000s, file2: 1000–1800s (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: 0–1000s, file2: 1000–1800s (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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue