mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +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>
|
</widgets-alert>
|
||||||
</div>
|
</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>
|
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -62,7 +82,10 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
selectedFileIno: null,
|
||||||
|
isChapterDownloading: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
|
|
@ -77,6 +100,18 @@ export default {
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.media.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() {
|
showM4bDownload() {
|
||||||
if (!this.mediaTracks.length) return false
|
if (!this.mediaTracks.length) return false
|
||||||
return true
|
return true
|
||||||
|
|
@ -103,7 +138,37 @@ export default {
|
||||||
return this.encodeTask && !this.encodeTask?.isFinished
|
return this.encodeTask && !this.encodeTask?.isFinished
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
audioFiles: {
|
||||||
|
immediate: true,
|
||||||
|
handler(files) {
|
||||||
|
if (files.length && !this.selectedFileIno) {
|
||||||
|
this.selectedFileIno = files[0].ino
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
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() {
|
quickEmbed() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$strings.MessageConfirmQuickEmbed,
|
message: this.$strings.MessageConfirmQuickEmbed,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@
|
||||||
"ButtonPurgeItemsCache": "Purge Items Cache",
|
"ButtonPurgeItemsCache": "Purge Items Cache",
|
||||||
"ButtonQueueAddItem": "Add to queue",
|
"ButtonQueueAddItem": "Add to queue",
|
||||||
"ButtonQueueRemoveItem": "Remove from queue",
|
"ButtonQueueRemoveItem": "Remove from queue",
|
||||||
|
"ButtonChapterDownloadZip": "Download ZIP",
|
||||||
|
"ButtonChapterDownloadZipPreparing": "Preparing...",
|
||||||
"ButtonQuickEmbed": "Quick Embed",
|
"ButtonQuickEmbed": "Quick Embed",
|
||||||
"ButtonQuickEmbedMetadata": "Quick Embed Metadata",
|
"ButtonQuickEmbedMetadata": "Quick Embed Metadata",
|
||||||
"ButtonQuickMatch": "Quick Match",
|
"ButtonQuickMatch": "Quick Match",
|
||||||
|
|
@ -685,6 +687,8 @@
|
||||||
"LabelToolsM4bEncoder": "M4B Encoder",
|
"LabelToolsM4bEncoder": "M4B Encoder",
|
||||||
"LabelToolsMakeM4b": "Make M4B Audiobook File",
|
"LabelToolsMakeM4b": "Make M4B Audiobook File",
|
||||||
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
|
"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",
|
"LabelToolsSplitM4b": "Split M4B to MP3's",
|
||||||
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
|
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
|
||||||
"LabelTotalDuration": "Total Duration",
|
"LabelTotalDuration": "Total Duration",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ const Database = require('../Database')
|
||||||
|
|
||||||
const zipHelpers = require('../utils/zipHelpers')
|
const zipHelpers = require('../utils/zipHelpers')
|
||||||
const { reqSupportsWebp } = require('../utils/index')
|
const { reqSupportsWebp } = require('../utils/index')
|
||||||
|
const Ffmpeg = require('../libs/fluentFfmpeg')
|
||||||
|
const archiver = require('../libs/archiver')
|
||||||
const { ScanResult, AudioMimeType } = require('../utils/constants')
|
const { ScanResult, AudioMimeType } = require('../utils/constants')
|
||||||
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
||||||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
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
|
* GET: /api/items/:id/download
|
||||||
* Download library item. Zip file if multiple files.
|
* 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?
|
* GET api/items/:id/ebook/:fileid?
|
||||||
* fileid is the inode value stored in LibraryFile.ino or EBookFile.ino
|
* 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.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.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', 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.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
|
||||||
this.router.get('/items/:id/cover', LibraryItemController.getCover.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))
|
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 })
|
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', () => {
|
describe('checkRemoveAuthorsAndSeries', () => {
|
||||||
let libraryItem1Id
|
let libraryItem1Id
|
||||||
let libraryItem2Id
|
let libraryItem2Id
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue