From 640c2f98b8b8ae4687dc3754bc8d20429fc2f8d4 Mon Sep 17 00:00:00 2001 From: Victor Da Luz Date: Tue, 14 Apr 2026 17:24:01 -0600 Subject: [PATCH] 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 --- client/components/modals/item/tabs/Tools.vue | 67 +++++++- client/strings/en-us.json | 4 + server/controllers/LibraryItemController.js | 152 ++++++++++++++++++ server/routers/ApiRouter.js | 1 + .../controllers/LibraryItemController.test.js | 124 ++++++++++++++ 5 files changed, 347 insertions(+), 1 deletion(-) diff --git a/client/components/modals/item/tabs/Tools.vue b/client/components/modals/item/tabs/Tools.vue index 8e40ca3c0..66f23ce53 100644 --- a/client/components/modals/item/tabs/Tools.vue +++ b/client/components/modals/item/tabs/Tools.vue @@ -48,6 +48,26 @@ + +
+
+
+

{{ $strings.LabelToolsDownloadByChapters }}

+

{{ $strings.LabelToolsDownloadByChaptersDescription }}

+
+
+
+ + + {{ $strings.ButtonChapterDownloadZipPreparing }} + {{ $strings.ButtonChapterDownloadZip }} + +
+
+
+

{{ $strings.MessageNoAudioTracks }}

@@ -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, diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..ff60d0379 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -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", diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 5f7bd9736..d0e852978 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -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 diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e89b364f3..408593b86 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -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)) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 5a0422392..4915702e7 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -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