From 2892e99384f9a1d17f4dfc204226a14e2695e922 Mon Sep 17 00:00:00 2001 From: wakamex Date: Mon, 29 Jun 2026 16:57:44 -0400 Subject: [PATCH 1/3] Add regression test for book duration with disabled tracks Demonstrates #5311: disabling an audio track via updateTracks does not recompute the stored book duration, so it keeps counting the excluded track. This test fails until the fix is applied (expects duration to drop to only the enabled track's length). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/LibraryItemController.test.js | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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) + }) + }) }) From 9cfe9964c23b5e88215cbe567a9c5d33c8c349ee Mon Sep 17 00:00:00 2001 From: wakamex Date: Mon, 29 Jun 2026 16:57:44 -0400 Subject: [PATCH 2/3] Fix book duration to exclude disabled audio tracks Add Book.updateDuration() which sums the included (non-excluded) audio files, and call it from updateTracks before saving so disabling a track updates the stored duration. Fixes #5311 Co-Authored-By: Claude Opus 4.8 (1M context) --- server/controllers/LibraryItemController.js | 2 ++ server/models/Book.js | 13 +++++++++++++ 2 files changed, 15 insertions(+) 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 } From 067d3f6394f3c9c9824ae7de8d45594494340062 Mon Sep 17 00:00:00 2001 From: wakamex Date: Mon, 29 Jun 2026 19:40:45 -0400 Subject: [PATCH 3/3] Also exclude disabled tracks from book duration on rescan BookScanner recomputed media.duration by summing all audio files, so a rescan that detected any file change would re-count a previously disabled (excluded) track and re-inflate the duration. Use the same Book.updateDuration() (included tracks only) so both the manage-tracks path and rescans stay consistent. Add a Book model unit test for updateDuration(). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/scanner/BookScanner.js | 8 ++--- test/server/models/Book.test.js | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 test/server/models/Book.test.js 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/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) + }) + }) +})