mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
Merge 067d3f6394 into 6f03467f35
This commit is contained in:
commit
bf18bb923a
5 changed files with 117 additions and 6 deletions
|
|
@ -507,6 +507,8 @@ class LibraryItemController {
|
||||||
|
|
||||||
req.libraryItem.media.audioFiles = updatedAudioFiles
|
req.libraryItem.media.audioFiles = updatedAudioFiles
|
||||||
req.libraryItem.media.changed('audioFiles', true)
|
req.libraryItem.media.changed('audioFiles', true)
|
||||||
|
// Recompute duration so disabled (excluded) tracks no longer count toward the total
|
||||||
|
req.libraryItem.media.updateDuration()
|
||||||
await req.libraryItem.media.save()
|
await req.libraryItem.media.save()
|
||||||
|
|
||||||
SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)
|
SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,19 @@ class Book extends Model {
|
||||||
return this.audioFiles.filter((af) => !af.exclude)
|
return this.audioFiles.filter((af) => !af.exclude)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate the book duration from the included (non-excluded) audio files.
|
||||||
|
* Sets `this.duration` and returns whether it changed.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
updateDuration() {
|
||||||
|
const newDuration = this.includedAudioFiles.reduce((total, af) => total + (isNaN(af.duration) ? 0 : Number(af.duration)), 0)
|
||||||
|
if (this.duration === newDuration) return false
|
||||||
|
this.duration = newDuration
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
get hasMediaFiles() {
|
get hasMediaFiles() {
|
||||||
return !!this.hasAudioTracks || !!this.ebookFile
|
return !!this.hasAudioTracks || !!this.ebookFile
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,12 +136,8 @@ class BookScanner {
|
||||||
|
|
||||||
media.audioFiles = AudioFileScanner.runSmartTrackOrder(existingLibraryItem.relPath, media.audioFiles)
|
media.audioFiles = AudioFileScanner.runSmartTrackOrder(existingLibraryItem.relPath, media.audioFiles)
|
||||||
|
|
||||||
media.duration = 0
|
// Recompute duration from included (non-excluded) tracks so disabled tracks don't get re-counted on rescan
|
||||||
media.audioFiles.forEach((af) => {
|
media.updateDuration()
|
||||||
if (!isNaN(af.duration)) {
|
|
||||||
media.duration += af.duration
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
media.changed('audioFiles', true)
|
media.changed('audioFiles', true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const LibraryItemController = require('../../../server/controllers/LibraryItemCo
|
||||||
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||||
const Auth = require('../../../server/Auth')
|
const Auth = require('../../../server/Auth')
|
||||||
const Logger = require('../../../server/Logger')
|
const Logger = require('../../../server/Logger')
|
||||||
|
const SocketAuthority = require('../../../server/SocketAuthority')
|
||||||
|
|
||||||
describe('LibraryItemController', () => {
|
describe('LibraryItemController', () => {
|
||||||
/** @type {ApiRouter} */
|
/** @type {ApiRouter} */
|
||||||
|
|
@ -299,4 +300,41 @@ describe('LibraryItemController', () => {
|
||||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('updateTracks', () => {
|
||||||
|
it('recomputes book duration to exclude disabled audio tracks', async () => {
|
||||||
|
const library = await Database.libraryModel.create({ name: 'Tracks Library', mediaType: 'book' })
|
||||||
|
const libraryFolder = await Database.libraryFolderModel.create({ path: '/tracks', libraryId: library.id })
|
||||||
|
|
||||||
|
// Book with two audio tracks (100s + 50s) and a stored duration of 150s
|
||||||
|
const audioFiles = [
|
||||||
|
{ index: 1, ino: 'file-1', duration: 100, exclude: false, metadata: { filename: 'track1.mp3', size: 1000 } },
|
||||||
|
{ index: 2, ino: 'file-2', duration: 50, exclude: false, metadata: { filename: 'track2.mp3', size: 1000 } }
|
||||||
|
]
|
||||||
|
const book = await Database.bookModel.create({ title: 'Two Track Book', duration: 150, audioFiles, tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id })
|
||||||
|
|
||||||
|
sinon.stub(SocketAuthority, 'libraryItemEmitter')
|
||||||
|
|
||||||
|
const expandedLibraryItem = await Database.libraryItemModel.getExpandedById(libraryItem.id)
|
||||||
|
const fakeReq = {
|
||||||
|
libraryItem: expandedLibraryItem,
|
||||||
|
body: {
|
||||||
|
orderedFileData: [
|
||||||
|
{ ino: 'file-1', exclude: false },
|
||||||
|
{ ino: 'file-2', exclude: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() }
|
||||||
|
|
||||||
|
await LibraryItemController.updateTracks.bind(apiRouter)(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.json.calledOnce).to.be.true
|
||||||
|
|
||||||
|
// Duration should now reflect only the enabled track (100s), not the original 150s
|
||||||
|
const updatedBook = await Database.bookModel.findByPk(book.id)
|
||||||
|
expect(updatedBook.duration).to.equal(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
62
test/server/models/Book.test.js
Normal file
62
test/server/models/Book.test.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
|
||||||
|
describe('Book', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
global.ServerSettings = {}
|
||||||
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
await Database.buildModels()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Database.sequelize.sync({ force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateDuration', () => {
|
||||||
|
it('sums only the included (non-excluded) audio files', () => {
|
||||||
|
const book = Database.bookModel.build({
|
||||||
|
title: 'Test Book',
|
||||||
|
duration: 0,
|
||||||
|
audioFiles: [
|
||||||
|
{ ino: '1', duration: 100, exclude: false },
|
||||||
|
{ ino: '2', duration: 50, exclude: true },
|
||||||
|
{ ino: '3', duration: 25, exclude: false }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const changed = book.updateDuration()
|
||||||
|
|
||||||
|
expect(book.duration).to.equal(125) // 100 + 25, the excluded 50 is ignored
|
||||||
|
expect(changed).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false and leaves duration unchanged when already correct', () => {
|
||||||
|
const book = Database.bookModel.build({
|
||||||
|
title: 'Test Book',
|
||||||
|
duration: 100,
|
||||||
|
audioFiles: [{ ino: '1', duration: 100, exclude: false }]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(book.updateDuration()).to.be.false
|
||||||
|
expect(book.duration).to.equal(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores audio files with a non-numeric duration', () => {
|
||||||
|
const book = Database.bookModel.build({
|
||||||
|
title: 'Test Book',
|
||||||
|
duration: 0,
|
||||||
|
audioFiles: [
|
||||||
|
{ ino: '1', duration: 100, exclude: false },
|
||||||
|
{ ino: '2', duration: NaN, exclude: false }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
book.updateDuration()
|
||||||
|
|
||||||
|
expect(book.duration).to.equal(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue