diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index 4834a1a25..fbcf0089f 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -258,6 +258,11 @@ export default { sublist: false }) } + items.push({ + text: 'Consolidated', + value: 'consolidated', + sublist: true + }) return items }, podcastItems() { @@ -303,7 +308,11 @@ export default { sublist: false }) } - + items.push({ + text: 'Consolidated', + value: 'consolidated', + sublist: true + }) return items }, selectItems() { @@ -350,6 +359,9 @@ export default { } else if (parts[0] === 'missing') { const item = this.missing.find((m) => m.id == decoded) if (item) filterValue = item.name + } else if (parts[0] === 'consolidated') { + const item = this.consolidated.find((c) => c.id == decoded) + if (item) filterValue = item.name } else { filterValue = decoded } @@ -504,6 +516,18 @@ export default { } ] }, + consolidated() { + return [ + { + id: 'consolidated', + name: 'Consolidated' + }, + { + id: 'not-consolidated', + name: 'Not Consolidated' + } + ] + }, sublistItems() { const sublistItems = (this[this.sublist] || []).map((item) => { if (typeof item === 'string') { diff --git a/docs/objects/LibraryItem.yaml b/docs/objects/LibraryItem.yaml index 107ba9f3f..99c87a0c6 100644 --- a/docs/objects/LibraryItem.yaml +++ b/docs/objects/LibraryItem.yaml @@ -53,6 +53,9 @@ components: isInvalid: description: Whether the library item was scanned and no longer has media files. type: boolean + isNotConsolidated: + description: Whether the library item folder name does not match the "Author - Title" format. + type: boolean mediaType: $ref: './mediaTypes/media.yaml#/components/schemas/mediaType' libraryItemMinified: diff --git a/package.json b/package.json index 3ee3fb391..9eff93943 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.32.1", + "version": "2.32.7", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", diff --git a/server/Database.js b/server/Database.js index 213c2c61b..ed4c12e10 100644 --- a/server/Database.js +++ b/server/Database.js @@ -401,6 +401,7 @@ class Database { // Update server settings with version/build let updateServerSettings = false + const oldVersion = this.serverSettings.version if (packageJson.version !== this.serverSettings.version) { Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`) this.serverSettings.version = packageJson.version @@ -413,9 +414,48 @@ class Database { } if (updateServerSettings) { await this.updateServerSettings() + + if (this.compareVersions(oldVersion, '2.32.7') < 0) { + await this.populateIsNotConsolidated() + } } } + async populateIsNotConsolidated() { + Logger.info(`[Database] Populating isNotConsolidated flag for all items...`) + const items = await this.models.libraryItem.findAll({ + include: [ + { + model: this.models.book, + include: { + model: this.models.author, + through: { + attributes: ['createdAt'] + } + } + }, + { + model: this.models.podcast + } + ] + }) + let count = 0 + let updatedCount = 0 + for (const item of items) { + count++ + // LibraryItem.js hook afterFind sets item.media + const isNotConsolidated = item.checkIsNotConsolidated() + + if (item.isNotConsolidated !== isNotConsolidated) { + Logger.debug(`[Database] Updating isNotConsolidated for ${item.relPath} to ${isNotConsolidated} (was ${item.isNotConsolidated})`) + item.isNotConsolidated = isNotConsolidated + await item.save() + updatedCount++ + } + } + Logger.info(`[Database] Finished populating isNotConsolidated flag. Checked ${count} items, updated ${updatedCount}`) + } + /** * Create root user * @param {string} username diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index aa581f2b5..af4528763 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -82,6 +82,7 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n libraryItem.relPath = newRelPath libraryItem.isMissing = false libraryItem.isInvalid = false + libraryItem.isNotConsolidated = libraryItem.checkIsNotConsolidated() libraryItem.changed('updatedAt', true) await libraryItem.save({ transaction }) diff --git a/server/migrations/v2.32.2-add-is-not-consolidated-column.js b/server/migrations/v2.32.2-add-is-not-consolidated-column.js new file mode 100644 index 000000000..c3e29be8c --- /dev/null +++ b/server/migrations/v2.32.2-add-is-not-consolidated-column.js @@ -0,0 +1,45 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.32.2' +const migrationName = `${migrationVersion}-add-is-not-consolidated-column` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * Adds isNotConsolidated column to libraryItems table. + * + * @param {MigrationOptions} options + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + const tableDescription = await queryInterface.describeTable('libraryItems') + if (!tableDescription['isNotConsolidated']) { + await queryInterface.addColumn('libraryItems', 'isNotConsolidated', { + type: queryInterface.sequelize.Sequelize.BOOLEAN, + defaultValue: false + }) + logger.info(`${loggerPrefix} added column "isNotConsolidated" to table "libraryItems"`) + } else { + logger.info(`${loggerPrefix} column "isNotConsolidated" already exists in table "libraryItems"`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + await queryInterface.removeColumn('libraryItems', 'isNotConsolidated') + logger.info(`${loggerPrefix} removed column "isNotConsolidated" from table "libraryItems"`) + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 5e5870d71..c71e02d64 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -688,7 +688,11 @@ class LibraryItem extends Model { title: DataTypes.STRING, titleIgnorePrefix: DataTypes.STRING, authorNamesFirstLast: DataTypes.STRING, - authorNamesLastFirst: DataTypes.STRING + authorNamesLastFirst: DataTypes.STRING, + isNotConsolidated: { + type: DataTypes.BOOLEAN, + defaultValue: false + } }, { sequelize, @@ -917,11 +921,17 @@ class LibraryItem extends Model { } checkIsNotConsolidated() { - if (this.isFile || this.mediaType !== 'book' || !this.media) return false - const author = this.media.authors?.[0]?.name || 'Unknown Author' - const title = this.media.title || 'Unknown Title' - const folderName = sanitizeFilename(`${author} - ${title}`) - return Path.basename(this.path) !== folderName + if (this.mediaType !== 'book') return false + if (this.isFile) return true + const author = this.authorNamesFirstLast?.split(',')[0]?.trim() || 'Unknown Author' + const title = this.title || 'Unknown Title' + const folderName = this.checkIsNotConsolidated_FolderName(author, title) + const currentFolderName = Path.basename(this.path.replace(/[\/\\]$/, '')) + return currentFolderName !== folderName + } + + checkIsNotConsolidated_FolderName(author, title) { + return sanitizeFilename(`${author} - ${title}`) } toOldJSON() { @@ -947,7 +957,7 @@ class LibraryItem extends Model { scanVersion: this.lastScanVersion, isMissing: !!this.isMissing, isInvalid: !!this.isInvalid, - isNotConsolidated: this.checkIsNotConsolidated(), + isNotConsolidated: !!this.isNotConsolidated, mediaType: this.mediaType, media: this.media.toOldJSON(this.id), // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database @@ -976,7 +986,7 @@ class LibraryItem extends Model { updatedAt: this.updatedAt.valueOf(), isMissing: !!this.isMissing, isInvalid: !!this.isInvalid, - isNotConsolidated: this.checkIsNotConsolidated(), + isNotConsolidated: !!this.isNotConsolidated, mediaType: this.mediaType, media: this.media.toOldJSONMinified(), numFiles: this.libraryFiles.length, @@ -1003,7 +1013,7 @@ class LibraryItem extends Model { scanVersion: this.lastScanVersion, isMissing: !!this.isMissing, isInvalid: !!this.isInvalid, - isNotConsolidated: this.checkIsNotConsolidated(), + isNotConsolidated: !!this.isNotConsolidated, mediaType: this.mediaType, media: this.media.toOldJSONExpanded(this.id), // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 4fa2528c5..ae36dcf88 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -382,8 +382,12 @@ class BookScanner { } existingLibraryItem.media = media - let libraryItemUpdated = false + const isNotConsolidated = existingLibraryItem.checkIsNotConsolidated() + if (existingLibraryItem.isNotConsolidated !== isNotConsolidated) { + existingLibraryItem.isNotConsolidated = isNotConsolidated + libraryItemUpdated = true + } // Save Book changes to db if (hasMediaChanges) { @@ -560,6 +564,7 @@ class BookScanner { } libraryItemObj.book = bookObject + libraryItemObj.isNotConsolidated = Database.libraryItemModel.prototype.checkIsNotConsolidated.call(libraryItemObj) const libraryItem = await Database.libraryItemModel.create(libraryItemObj, { include: { model: Database.bookModel, diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index c9569c3ad..6249cf9bc 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -232,8 +232,12 @@ class PodcastScanner { } existingLibraryItem.media = media - let libraryItemUpdated = false + const isNotConsolidated = existingLibraryItem.checkIsNotConsolidated() + if (existingLibraryItem.isNotConsolidated !== isNotConsolidated) { + existingLibraryItem.isNotConsolidated = isNotConsolidated + libraryItemUpdated = true + } // Save Podcast changes to db if (hasMediaChanges) { @@ -337,6 +341,7 @@ class PodcastScanner { } libraryItemObj.podcast = podcastObject + libraryItemObj.isNotConsolidated = Database.libraryItemModel.prototype.checkIsNotConsolidated.call(libraryItemObj) const libraryItem = await Database.libraryItemModel.create(libraryItemObj, { include: { model: Database.podcastModel, diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 7312b9d5d..d64553be8 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -27,7 +27,7 @@ module.exports = { let filterValue = null let filterGroup = null if (filterBy) { - const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'publishedDecades', 'missing', 'languages', 'tracks', 'ebooks'] + const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'publishedDecades', 'missing', 'languages', 'tracks', 'ebooks', 'consolidated'] const group = searchGroups.find((_group) => filterBy.startsWith(_group + '.')) filterGroup = group || filterBy filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 7ae1dc866..f128b2d0c 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -239,6 +239,8 @@ module.exports = { mediaWhere = Sequelize.where(Sequelize.literal('CAST(publishedYear AS INTEGER)'), { [Sequelize.Op.between]: [startYear, endYear] }) + } else if (group === 'consolidated') { + // This is handled in libraryItemWhere in getFilteredLibraryItems } return { mediaWhere, replacements } @@ -531,6 +533,8 @@ module.exports = { libraryItemWhere['createdAt'] = { [Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago } + } else if (filterGroup === 'consolidated') { + libraryItemWhere['isNotConsolidated'] = filterValue === 'not-consolidated' } // When sorting by progress but not filtering by progress, include media progresses diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 8bb5dc110..6bcddabf2 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -172,6 +172,8 @@ module.exports = { libraryItemWhere['createdAt'] = { [Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago } + } else if (filterGroup === 'consolidated') { + libraryItemWhere['isNotConsolidated'] = filterValue === 'not-consolidated' } const podcastIncludes = []