This commit is contained in:
mikiher 2026-07-10 17:37:39 +03:00 committed by GitHub
commit 567caa0c66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 81 additions and 7 deletions

View file

@ -1,4 +1,4 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model, fn, col } = require('sequelize')
class BookAuthor extends Model { class BookAuthor extends Model {
constructor(values, options) { constructor(values, options) {
@ -37,6 +37,32 @@ class BookAuthor extends Model {
}) })
} }
/**
* Get number of books for each author
*
* @param {string[]} authorIds
* @returns {Promise<Record<string, number>>}
*/
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<string, number>} */
const counts = {}
for (const row of rows) {
counts[row.authorId] = Number(row.count)
}
return counts
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize

View file

@ -228,6 +228,7 @@ class BookScanner {
bookId: media.id, bookId: media.id,
authorId: existingAuthorId authorId: existingAuthorId
}) })
libraryScan.authorsNumBooksChangedIds.add(existingAuthorId)
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`) libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`)
authorsUpdated = true authorsUpdated = true
} else { } else {
@ -238,6 +239,7 @@ class BookScanner {
}) })
await media.addAuthor(newAuthor) await media.addAuthor(newAuthor)
Database.addAuthorToFilterData(libraryItemData.libraryId, newAuthor.name, newAuthor.id) Database.addAuthorToFilterData(libraryItemData.libraryId, newAuthor.name, newAuthor.id)
SocketAuthority.emitter('author_added', newAuthor.toOldJSON())
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new author "${authorName}"`) libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new author "${authorName}"`)
authorsUpdated = true authorsUpdated = true
} }
@ -247,6 +249,7 @@ class BookScanner {
for (const author of media.authors) { for (const author of media.authors) {
if (!bookMetadata.authors.includes(author.name)) { if (!bookMetadata.authors.includes(author.name)) {
await author.bookAuthor.destroy() await author.bookAuthor.destroy()
libraryScan.authorsNumBooksChangedIds.add(author.id)
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed author "${author.name}"`) libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed author "${author.name}"`)
authorsUpdated = true authorsUpdated = true
bookAuthorsRemoved.push(author.id) bookAuthorsRemoved.push(author.id)
@ -478,6 +481,7 @@ class BookScanner {
} }
const createdAtTimestamp = new Date().getTime() const createdAtTimestamp = new Date().getTime()
const newAuthorNames = new Set()
if (bookMetadata.authors.length) { if (bookMetadata.authors.length) {
for (const authorName of bookMetadata.authors) { for (const authorName of bookMetadata.authors) {
const matchingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName) const matchingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName)
@ -485,6 +489,8 @@ class BookScanner {
bookObject.bookAuthors.push({ bookObject.bookAuthors.push({
authorId: matchingAuthorId authorId: matchingAuthorId
}) })
// Existing author — only numBooks changes; batch emit at scan end
libraryScan.authorsNumBooksChangedIds.add(matchingAuthorId)
} else { } else {
// New author // New author
bookObject.bookAuthors.push({ bookObject.bookAuthors.push({
@ -496,6 +502,7 @@ class BookScanner {
lastFirst: Database.authorModel.getLastFirst(authorName) lastFirst: Database.authorModel.getLastFirst(authorName)
} }
}) })
newAuthorNames.add(authorName)
} }
} }
} }
@ -593,6 +600,9 @@ class BookScanner {
for (const ba of libraryItem.book.bookAuthors) { for (const ba of libraryItem.book.bookAuthors) {
if (ba.author) { if (ba.author) {
Database.addAuthorToFilterData(libraryItemData.libraryId, ba.author.name, ba.author.id) Database.addAuthorToFilterData(libraryItemData.libraryId, ba.author.name, ba.author.id)
if (newAuthorNames.has(ba.author.name)) {
SocketAuthority.emitter('author_added', ba.author.toOldJSON())
}
} }
} }
} }
@ -891,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 * 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 * keep authors without books that have a asin, description or imagePath

View file

@ -66,7 +66,7 @@ class LibraryItemScanner {
if (libraryItemDataUpdated || wasUpdated) { if (libraryItemDataUpdated || wasUpdated) {
SocketAuthority.libraryItemEmitter('item_updated', expandedLibraryItem) SocketAuthority.libraryItemEmitter('item_updated', expandedLibraryItem)
await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger) await this.finalizeAuthorsAndSeriesFromScan(library.id, scanLogger)
return ScanResult.UPDATED 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 {string} libraryId
* @param {ScanLogger} scanLogger * @param {ScanLogger} scanLogger
* @returns {Promise} * @returns {Promise}
*/ */
async checkAuthorsAndSeriesRemovedFromBooks(libraryId, scanLogger) { async finalizeAuthorsAndSeriesFromScan(libraryId, scanLogger) {
await BookScanner.emitAuthorsNumBooksUpdated(libraryId, scanLogger)
if (scanLogger.authorsRemovedFromBooks.length) { if (scanLogger.authorsRemovedFromBooks.length) {
await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger) await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger)
} }
@ -215,7 +216,10 @@ class LibraryItemScanner {
scanLogger.verbose = true scanLogger.verbose = true
scanLogger.setData('libraryItem', libraryItemScanData.relPath) 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() module.exports = new LibraryItemScanner()

View file

@ -24,6 +24,8 @@ class LibraryScan {
/** @type {string[]} */ /** @type {string[]} */
this.authorsRemovedFromBooks = [] this.authorsRemovedFromBooks = []
/** @type {Set<string>} */
this.authorsNumBooksChangedIds = new Set()
/** @type {string[]} */ /** @type {string[]} */
this.seriesRemovedFromBooks = [] this.seriesRemovedFromBooks = []

View file

@ -234,8 +234,7 @@ class LibraryScanner {
SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated) SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated)
} }
// Authors and series that were removed from books should be removed if they are now empty if (this.shouldCancelScan(libraryScan)) return true
await LibraryItemScanner.checkAuthorsAndSeriesRemovedFromBooks(libraryScan.libraryId, libraryScan)
// Update missing library items // Update missing library items
if (libraryItemIdsMissing.length) { 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}`) libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`)
return false return false
} }

View file

@ -14,6 +14,8 @@ class ScanLogger {
/** @type {string[]} */ /** @type {string[]} */
this.authorsRemovedFromBooks = [] this.authorsRemovedFromBooks = []
/** @type {Set<string>} */
this.authorsNumBooksChangedIds = new Set()
/** @type {string[]} */ /** @type {string[]} */
this.seriesRemovedFromBooks = [] this.seriesRemovedFromBooks = []