From 9d9ab49664c24f03a740f8712f995e6c73e05f03 Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 10 Jul 2026 17:37:31 +0300 Subject: [PATCH] Batch author numBooks updates at scan end via authors_num_books_updated --- server/models/BookAuthor.js | 28 ++++++++++++++++++- server/scanner/BookScanner.js | 42 +++++++++++++++++++++------- server/scanner/LibraryItemScanner.js | 12 +++++--- server/scanner/LibraryScan.js | 2 ++ server/scanner/LibraryScanner.js | 6 ++-- server/scanner/ScanLogger.js | 2 ++ 6 files changed, 75 insertions(+), 17 deletions(-) diff --git a/server/models/BookAuthor.js b/server/models/BookAuthor.js index d7d65728e..f2cbf60f0 100644 --- a/server/models/BookAuthor.js +++ b/server/models/BookAuthor.js @@ -1,4 +1,4 @@ -const { DataTypes, Model } = require('sequelize') +const { DataTypes, Model, fn, col } = require('sequelize') class BookAuthor extends Model { constructor(values, options) { @@ -37,6 +37,32 @@ class BookAuthor extends Model { }) } + /** + * Get number of books for each author + * + * @param {string[]} authorIds + * @returns {Promise>} + */ + static async getCountsForAuthors(authorIds) { + if (!authorIds.length) return {} + + const rows = await this.findAll({ + attributes: ['authorId', [fn('COUNT', col('id')), 'count']], + where: { + authorId: authorIds + }, + group: ['authorId'], + raw: true + }) + + /** @type {Record} */ + const counts = {} + for (const row of rows) { + counts[row.authorId] = Number(row.count) + } + return counts + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 757b1814a..2b106cdc8 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -228,11 +228,7 @@ class BookScanner { bookId: media.id, authorId: existingAuthorId }) - const author = await Database.authorModel.findByPk(existingAuthorId) - if (author) { - const numBooks = await Database.bookAuthorModel.getCountForAuthor(existingAuthorId) - SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks)) - } + libraryScan.authorsNumBooksChangedIds.add(existingAuthorId) libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`) authorsUpdated = true } else { @@ -253,6 +249,7 @@ class BookScanner { for (const author of media.authors) { if (!bookMetadata.authors.includes(author.name)) { await author.bookAuthor.destroy() + libraryScan.authorsNumBooksChangedIds.add(author.id) libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed author "${author.name}"`) authorsUpdated = true bookAuthorsRemoved.push(author.id) @@ -484,7 +481,6 @@ class BookScanner { } const createdAtTimestamp = new Date().getTime() - const linkedAuthorIds = new Set() const newAuthorNames = new Set() if (bookMetadata.authors.length) { for (const authorName of bookMetadata.authors) { @@ -493,7 +489,8 @@ class BookScanner { bookObject.bookAuthors.push({ authorId: matchingAuthorId }) - linkedAuthorIds.add(matchingAuthorId) + // Existing author — only numBooks changes; batch emit at scan end + libraryScan.authorsNumBooksChangedIds.add(matchingAuthorId) } else { // New author bookObject.bookAuthors.push({ @@ -605,9 +602,6 @@ class BookScanner { Database.addAuthorToFilterData(libraryItemData.libraryId, ba.author.name, ba.author.id) if (newAuthorNames.has(ba.author.name)) { SocketAuthority.emitter('author_added', ba.author.toOldJSON()) - } else if (linkedAuthorIds.has(ba.author.id)) { - const numBooks = await Database.bookAuthorModel.getCountForAuthor(ba.author.id) - SocketAuthority.emitter('author_updated', ba.author.toOldJSONExpanded(numBooks)) } } } @@ -907,6 +901,34 @@ class BookScanner { }) } + /** + * Emit authors_num_books_updated for authors whose book count changed during a scan (linked or unlinked). + * Authors with no book links are omitted — orphans are handled by author_removed. + * @param {string} libraryId + * @param {import('./LibraryScan')|import('./ScanLogger')} scanLogger + * @returns {Promise} + */ + async emitAuthorsNumBooksUpdated(libraryId, scanLogger) { + if (!scanLogger.authorsNumBooksChangedIds?.size) return + + const authorIds = [...scanLogger.authorsNumBooksChangedIds] + scanLogger.authorsNumBooksChangedIds.clear() + + const countsByAuthorId = await Database.bookAuthorModel.getCountsForAuthors(authorIds) + + const authors = Object.entries(countsByAuthorId).map(([id, numBooks]) => ({ + id, + numBooks + })) + + if (!authors.length) return + + SocketAuthority.emitter('authors_num_books_updated', { + libraryId, + authors + }) + } + /** * Check authors that were removed from a book and remove them if they no longer have any books * keep authors without books that have a asin, description or imagePath diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index 501df4274..de95c9a09 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -66,7 +66,7 @@ class LibraryItemScanner { if (libraryItemDataUpdated || wasUpdated) { SocketAuthority.libraryItemEmitter('item_updated', expandedLibraryItem) - await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger) + await this.finalizeAuthorsAndSeriesFromScan(library.id, scanLogger) return ScanResult.UPDATED } @@ -76,12 +76,13 @@ class LibraryItemScanner { } /** - * Remove empty authors and series + * Emit author book count updates, then remove empty authors/series left after the scan * @param {string} libraryId * @param {ScanLogger} scanLogger * @returns {Promise} */ - async checkAuthorsAndSeriesRemovedFromBooks(libraryId, scanLogger) { + async finalizeAuthorsAndSeriesFromScan(libraryId, scanLogger) { + await BookScanner.emitAuthorsNumBooksUpdated(libraryId, scanLogger) if (scanLogger.authorsRemovedFromBooks.length) { await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger) } @@ -215,7 +216,10 @@ class LibraryItemScanner { scanLogger.verbose = true scanLogger.setData('libraryItem', libraryItemScanData.relPath) - return this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger) + const newLibraryItem = await this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger) + // Watcher path does not go through full-library scan cleanup — emit author book count updates here + await BookScanner.emitAuthorsNumBooksUpdated(library.id, scanLogger) + return newLibraryItem } } module.exports = new LibraryItemScanner() diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js index e77f30ba6..0366addba 100644 --- a/server/scanner/LibraryScan.js +++ b/server/scanner/LibraryScan.js @@ -24,6 +24,8 @@ class LibraryScan { /** @type {string[]} */ this.authorsRemovedFromBooks = [] + /** @type {Set} */ + this.authorsNumBooksChangedIds = new Set() /** @type {string[]} */ this.seriesRemovedFromBooks = [] diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 640c82d76..73cb51553 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -234,8 +234,7 @@ class LibraryScanner { SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated) } - // Authors and series that were removed from books should be removed if they are now empty - await LibraryItemScanner.checkAuthorsAndSeriesRemovedFromBooks(libraryScan.libraryId, libraryScan) + if (this.shouldCancelScan(libraryScan)) return true // Update missing library items if (libraryItemIdsMissing.length) { @@ -281,6 +280,9 @@ class LibraryScanner { } } + // Author book count updates and empty authors/series cleanup after all items are processed + await LibraryItemScanner.finalizeAuthorsAndSeriesFromScan(libraryScan.libraryId, libraryScan) + libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`) return false } diff --git a/server/scanner/ScanLogger.js b/server/scanner/ScanLogger.js index 7aed21f13..2748f1f3b 100644 --- a/server/scanner/ScanLogger.js +++ b/server/scanner/ScanLogger.js @@ -14,6 +14,8 @@ class ScanLogger { /** @type {string[]} */ this.authorsRemovedFromBooks = [] + /** @type {Set} */ + this.authorsNumBooksChangedIds = new Set() /** @type {string[]} */ this.seriesRemovedFromBooks = []