diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 07b4ee67f..ddfe2fa34 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -507,6 +507,8 @@ class LibraryItemController { req.libraryItem.media.audioFiles = updatedAudioFiles 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() SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem) diff --git a/server/models/Book.js b/server/models/Book.js index d9f2ff132..2e4d59bee 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -261,6 +261,19 @@ class Book extends Model { 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() { return !!this.hasAudioTracks || !!this.ebookFile } diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 8ae616fe8..ade7260d9 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -136,12 +136,8 @@ class BookScanner { media.audioFiles = AudioFileScanner.runSmartTrackOrder(existingLibraryItem.relPath, media.audioFiles) - media.duration = 0 - media.audioFiles.forEach((af) => { - if (!isNaN(af.duration)) { - media.duration += af.duration - } - }) + // Recompute duration from included (non-excluded) tracks so disabled tracks don't get re-counted on rescan + media.updateDuration() media.changed('audioFiles', true) } diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 1d57219d3..c19059533 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -8,6 +8,7 @@ const LibraryItemController = require('../../../server/controllers/LibraryItemCo const ApiCacheManager = require('../../../server/managers/ApiCacheManager') const Auth = require('../../../server/Auth') const Logger = require('../../../server/Logger') +const SocketAuthority = require('../../../server/SocketAuthority') describe('LibraryItemController', () => { /** @type {ApiRouter} */ @@ -299,4 +300,41 @@ describe('LibraryItemController', () => { 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) + }) + }) }) diff --git a/test/server/models/Book.test.js b/test/server/models/Book.test.js new file mode 100644 index 000000000..162dbe92c --- /dev/null +++ b/test/server/models/Book.test.js @@ -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) + }) + }) +})