const util = require('util') const Path = require('path') const { DataTypes, Model } = require('sequelize') const fsExtra = require('../libs/fsExtra') const Logger = require('../Logger') const oldLibraryItem = require('../objects/LibraryItem') const libraryFilters = require('../utils/queries/libraryFilters') const { areEquivalent } = require('../utils/index') const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') const LibraryFile = require('../objects/files/LibraryFile') const Book = require('./Book') const Podcast = require('./Podcast') const ShareManager = require('../managers/ShareManager') /** * @typedef LibraryFileObject * @property {string} ino * @property {boolean} isSupplementary * @property {number} addedAt * @property {number} updatedAt * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata */ /** * @typedef LibraryItemExpandedProperties * @property {Book.BookExpanded|Podcast.PodcastExpanded} media * * @typedef {LibraryItem & LibraryItemExpandedProperties} LibraryItemExpanded */ class LibraryItem extends Model { constructor(values, options) { super(values, options) /** @type {string} */ this.id /** @type {string} */ this.ino /** @type {string} */ this.path /** @type {string} */ this.relPath /** @type {string} */ this.mediaId /** @type {string} */ this.mediaType /** @type {boolean} */ this.isFile /** @type {boolean} */ this.isMissing /** @type {boolean} */ this.isInvalid /** @type {Date} */ this.mtime /** @type {Date} */ this.ctime /** @type {Date} */ this.birthtime /** @type {BigInt} */ this.size /** @type {Date} */ this.lastScan /** @type {string} */ this.lastScanVersion /** @type {LibraryFileObject[]} */ this.libraryFiles /** @type {Object} */ this.extraData /** @type {string} */ this.libraryId /** @type {string} */ this.libraryFolderId /** @type {Date} */ this.createdAt /** @type {Date} */ this.updatedAt } /** * Gets library items partially expanded, not including podcast episodes * @todo temporary solution * * @param {number} offset * @param {number} limit * @returns {Promise} LibraryItem */ static getLibraryItemsIncrement(offset, limit, where = null) { return this.findAll({ where, include: [ { model: this.sequelize.models.book, include: [ { model: this.sequelize.models.author, through: { attributes: ['createdAt'] } }, { model: this.sequelize.models.series, through: { attributes: ['sequence', 'createdAt'] } } ] }, { model: this.sequelize.models.podcast } ], order: [ ['createdAt', 'ASC'], // Ensure author & series stay in the same order [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] ], offset, limit }) } /** * Currently unused because this is too slow and uses too much mem * @param {import('sequelize').WhereOptions} [where] * @returns {Array} old library items */ static async getAllOldLibraryItems(where = null) { let libraryItems = await this.findAll({ where, include: [ { model: this.sequelize.models.book, include: [ { model: this.sequelize.models.author, through: { attributes: [] } }, { model: this.sequelize.models.series, through: { attributes: ['sequence'] } } ] }, { model: this.sequelize.models.podcast, include: [ { model: this.sequelize.models.podcastEpisode } ] } ] }) return libraryItems.map((ti) => this.getOldLibraryItem(ti)) } /** * Convert an expanded LibraryItem into an old library item * * @param {Model} libraryItemExpanded * @returns {oldLibraryItem} */ static getOldLibraryItem(libraryItemExpanded) { let media = null if (libraryItemExpanded.mediaType === 'book') { media = this.sequelize.models.book.getOldBook(libraryItemExpanded) } else if (libraryItemExpanded.mediaType === 'podcast') { media = this.sequelize.models.podcast.getOldPodcast(libraryItemExpanded) } return new oldLibraryItem({ id: libraryItemExpanded.id, ino: libraryItemExpanded.ino, oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null, libraryId: libraryItemExpanded.libraryId, folderId: libraryItemExpanded.libraryFolderId, path: libraryItemExpanded.path, relPath: libraryItemExpanded.relPath, isFile: libraryItemExpanded.isFile, mtimeMs: libraryItemExpanded.mtime?.valueOf(), ctimeMs: libraryItemExpanded.ctime?.valueOf(), birthtimeMs: libraryItemExpanded.birthtime?.valueOf(), addedAt: libraryItemExpanded.createdAt.valueOf(), updatedAt: libraryItemExpanded.updatedAt.valueOf(), lastScan: libraryItemExpanded.lastScan?.valueOf(), scanVersion: libraryItemExpanded.lastScanVersion, isMissing: !!libraryItemExpanded.isMissing, isInvalid: !!libraryItemExpanded.isInvalid, mediaType: libraryItemExpanded.mediaType, media, libraryFiles: libraryItemExpanded.libraryFiles }) } static async fullCreateFromOld(oldLibraryItem) { const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem)) if (oldLibraryItem.mediaType === 'book') { const bookObj = this.sequelize.models.book.getFromOld(oldLibraryItem.media) bookObj.libraryItemId = newLibraryItem.id const newBook = await this.sequelize.models.book.create(bookObj) const oldBookAuthors = oldLibraryItem.media.metadata.authors || [] const oldBookSeriesAll = oldLibraryItem.media.metadata.series || [] for (const oldBookAuthor of oldBookAuthors) { await this.sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id }) } for (const oldSeries of oldBookSeriesAll) { await this.sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence }) } } else if (oldLibraryItem.mediaType === 'podcast') { const podcastObj = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media) podcastObj.libraryItemId = newLibraryItem.id const newPodcast = await this.sequelize.models.podcast.create(podcastObj) const oldEpisodes = oldLibraryItem.media.episodes || [] for (const oldEpisode of oldEpisodes) { const episodeObj = this.sequelize.models.podcastEpisode.getFromOld(oldEpisode) episodeObj.libraryItemId = newLibraryItem.id episodeObj.podcastId = newPodcast.id await this.sequelize.models.podcastEpisode.create(episodeObj) } } return newLibraryItem } /** * Updates libraryItem, book, authors and series from old library item * * @param {oldLibraryItem} oldLibraryItem * @returns {Promise} true if updates were made */ static async fullUpdateFromOld(oldLibraryItem) { const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, { include: [ { model: this.sequelize.models.book, include: [ { model: this.sequelize.models.author, through: { attributes: [] } }, { model: this.sequelize.models.series, through: { attributes: ['id', 'sequence'] } } ] }, { model: this.sequelize.models.podcast, include: [ { model: this.sequelize.models.podcastEpisode } ] } ] }) if (!libraryItemExpanded) return false let hasUpdates = false // Check update Book/Podcast if (libraryItemExpanded.media) { let updatedMedia = null if (libraryItemExpanded.mediaType === 'podcast') { updatedMedia = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media) const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || [] const updatedPodcastEpisodes = oldLibraryItem.media.episodes || [] for (const existingPodcastEpisode of existingPodcastEpisodes) { // Episode was removed if (!updatedPodcastEpisodes.some((ep) => ep.id === existingPodcastEpisode.id)) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) await existingPodcastEpisode.destroy() hasUpdates = true } } for (const updatedPodcastEpisode of updatedPodcastEpisodes) { const existingEpisodeMatch = existingPodcastEpisodes.find((ep) => ep.id === updatedPodcastEpisode.id) if (!existingEpisodeMatch) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) hasUpdates = true } else { const updatedEpisodeCleaned = this.sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode) let episodeHasUpdates = false for (const key in updatedEpisodeCleaned) { let existingValue = existingEpisodeMatch[key] if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) { Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from %j to %j`, existingValue, updatedEpisodeCleaned[key])) episodeHasUpdates = true } } if (episodeHasUpdates) { await existingEpisodeMatch.update(updatedEpisodeCleaned) hasUpdates = true } } } } else if (libraryItemExpanded.mediaType === 'book') { updatedMedia = this.sequelize.models.book.getFromOld(oldLibraryItem.media) const existingAuthors = libraryItemExpanded.media.authors || [] const existingSeriesAll = libraryItemExpanded.media.series || [] const updatedAuthors = oldLibraryItem.media.metadata.authors || [] const uniqueUpdatedAuthors = updatedAuthors.filter((au, idx) => updatedAuthors.findIndex((a) => a.id === au.id) === idx) const updatedSeriesAll = oldLibraryItem.media.metadata.series || [] for (const existingAuthor of existingAuthors) { // Author was removed from Book if (!uniqueUpdatedAuthors.some((au) => au.id === existingAuthor.id)) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) hasUpdates = true } } for (const updatedAuthor of uniqueUpdatedAuthors) { // Author was added if (!existingAuthors.some((au) => au.id === updatedAuthor.id)) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) hasUpdates = true } } for (const existingSeries of existingSeriesAll) { // Series was removed if (!updatedSeriesAll.some((se) => se.id === existingSeries.id)) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) hasUpdates = true } } for (const updatedSeries of updatedSeriesAll) { // Series was added/updated const existingSeriesMatch = existingSeriesAll.find((se) => se.id === updatedSeries.id) if (!existingSeriesMatch) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) hasUpdates = true } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence }) hasUpdates = true } } } let hasMediaUpdates = false for (const key in updatedMedia) { let existingValue = libraryItemExpanded.media[key] if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedMedia[key], existingValue, true)) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) hasMediaUpdates = true } } if (hasMediaUpdates && updatedMedia) { await libraryItemExpanded.media.update(updatedMedia) hasUpdates = true } } const updatedLibraryItem = this.getFromOld(oldLibraryItem) let hasLibraryItemUpdates = false for (const key in updatedLibraryItem) { let existingValue = libraryItemExpanded[key] if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) { if (key === 'libraryFiles') { // Handle logging of libraryFiles separately because the object is large (should be addressed when migrating off the old library item model) const libraryFilesRemoved = libraryItemExpanded.libraryFiles.filter((lf) => !updatedLibraryItem.libraryFiles.some((ulf) => ulf.ino === lf.ino)) if (libraryFilesRemoved.length) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files removed: ${libraryFilesRemoved.map((lf) => lf.metadata.path).join(', ')}`) } const libraryFilesAdded = updatedLibraryItem.libraryFiles.filter((ulf) => !libraryItemExpanded.libraryFiles.some((lf) => lf.ino === ulf.ino)) if (libraryFilesAdded.length) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files added: ${libraryFilesAdded.map((lf) => lf.metadata.path).join(', ')}`) } if (!libraryFilesRemoved.length && !libraryFilesAdded.length) { Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files updated`) } } else { Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from %j to %j`, existingValue, updatedLibraryItem[key])) } hasLibraryItemUpdates = true if (key === 'updatedAt') { libraryItemExpanded.changed('updatedAt', true) } } } if (hasLibraryItemUpdates) { await libraryItemExpanded.update(updatedLibraryItem) Logger.info(`[LibraryItem] Library item "${libraryItemExpanded.id}" updated`) hasUpdates = true } return hasUpdates } static getFromOld(oldLibraryItem) { const extraData = {} if (oldLibraryItem.oldLibraryItemId) { extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId } return { id: oldLibraryItem.id, ino: oldLibraryItem.ino, path: oldLibraryItem.path, relPath: oldLibraryItem.relPath, mediaId: oldLibraryItem.media.id, mediaType: oldLibraryItem.mediaType, isFile: !!oldLibraryItem.isFile, isMissing: !!oldLibraryItem.isMissing, isInvalid: !!oldLibraryItem.isInvalid, mtime: oldLibraryItem.mtimeMs, ctime: oldLibraryItem.ctimeMs, updatedAt: oldLibraryItem.updatedAt, birthtime: oldLibraryItem.birthtimeMs, size: oldLibraryItem.size, lastScan: oldLibraryItem.lastScan, lastScanVersion: oldLibraryItem.scanVersion, libraryId: oldLibraryItem.libraryId, libraryFolderId: oldLibraryItem.folderId, libraryFiles: oldLibraryItem.libraryFiles?.map((lf) => lf.toJSON()) || [], extraData } } /** * Remove library item by id * * @param {string} libraryItemId * @returns {Promise} The number of destroyed rows */ static removeById(libraryItemId) { return this.destroy({ where: { id: libraryItemId }, individualHooks: true }) } /** * * @param {string} libraryItemId * @returns {Promise} */ static async getExpandedById(libraryItemId) { if (!libraryItemId) return null const libraryItem = await this.findByPk(libraryItemId) if (!libraryItem) { Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`) return null } if (libraryItem.mediaType === 'podcast') { libraryItem.media = await libraryItem.getMedia({ include: [ { model: this.sequelize.models.podcastEpisode } ] }) } else { libraryItem.media = await libraryItem.getMedia({ include: [ { model: this.sequelize.models.author, through: { attributes: [] } }, { model: this.sequelize.models.series, through: { attributes: ['sequence'] } } ], order: [ [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] ] }) } if (!libraryItem.media) return null return libraryItem } /** * Get old library item by id * @param {string} libraryItemId * @returns {oldLibraryItem} */ static async getOldById(libraryItemId) { if (!libraryItemId) return null const libraryItem = await this.findByPk(libraryItemId) if (!libraryItem) { Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`) return null } if (libraryItem.mediaType === 'podcast') { libraryItem.media = await libraryItem.getMedia({ include: [ { model: this.sequelize.models.podcastEpisode } ] }) } else { libraryItem.media = await libraryItem.getMedia({ include: [ { model: this.sequelize.models.author, through: { attributes: [] } }, { model: this.sequelize.models.series, through: { attributes: ['sequence'] } } ], order: [ [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] ] }) } if (!libraryItem.media) return null return this.getOldLibraryItem(libraryItem) } /** * Get library items using filter and sort * @param {oldLibrary} library * @param {import('./User')} user * @param {object} options * @returns {{ libraryItems:oldLibraryItem[], count:number }} */ static async getByFilterAndSort(library, user, options) { let start = Date.now() const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library, user, options) Logger.debug(`Loaded ${libraryItems.length} of ${count} items for libary page in ${((Date.now() - start) / 1000).toFixed(2)}s`) return { libraryItems: libraryItems.map((li) => { const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() if (li.collapsedSeries) { oldLibraryItem.collapsedSeries = li.collapsedSeries } if (li.series) { oldLibraryItem.media.metadata.series = li.series } if (li.rssFeed) { oldLibraryItem.rssFeed = this.sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified() } if (li.media.numEpisodes) { oldLibraryItem.media.numEpisodes = li.media.numEpisodes } if (li.size && !oldLibraryItem.media.size) { oldLibraryItem.media.size = li.size } if (li.numEpisodesIncomplete) { oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete } if (li.mediaItemShare) { oldLibraryItem.mediaItemShare = li.mediaItemShare } return oldLibraryItem }), count } } /** * Get home page data personalized shelves * @param {oldLibrary} library * @param {import('./User')} user * @param {string[]} include * @param {number} limit * @returns {object[]} array of shelf objects */ static async getPersonalizedShelves(library, user, include, limit) { const fullStart = Date.now() // Used for testing load times const shelves = [] // "Continue Listening" shelf const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false) if (itemsInProgressPayload.items.length) { const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.isEBookOnly) const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => !li.media.isEBookOnly) shelves.push({ id: 'continue-listening', label: 'Continue Listening', labelStringKey: 'LabelContinueListening', type: library.isPodcast ? 'episode' : 'book', entities: audioOnlyItemsInProgress, total: itemsInProgressPayload.count }) if (ebookOnlyItemsInProgress.length) { // "Continue Reading" shelf shelves.push({ id: 'continue-reading', label: 'Continue Reading', labelStringKey: 'LabelContinueReading', type: 'book', entities: ebookOnlyItemsInProgress, total: itemsInProgressPayload.count }) } } Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) let start = Date.now() if (library.isBook) { start = Date.now() // "Continue Series" shelf const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit) if (continueSeriesPayload.libraryItems.length) { shelves.push({ id: 'continue-series', label: 'Continue Series', labelStringKey: 'LabelContinueSeries', type: 'book', entities: continueSeriesPayload.libraryItems, total: continueSeriesPayload.count }) } Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } else if (library.isPodcast) { // "Newest Episodes" shelf const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit) if (newestEpisodesPayload.libraryItems.length) { shelves.push({ id: 'newest-episodes', label: 'Newest Episodes', labelStringKey: 'LabelNewestEpisodes', type: 'episode', entities: newestEpisodesPayload.libraryItems, total: newestEpisodesPayload.count }) } Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } start = Date.now() // "Recently Added" shelf const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit) if (mostRecentPayload.libraryItems.length) { shelves.push({ id: 'recently-added', label: 'Recently Added', labelStringKey: 'LabelRecentlyAdded', type: library.mediaType, entities: mostRecentPayload.libraryItems, total: mostRecentPayload.count }) } Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) if (library.isBook) { start = Date.now() // "Recent Series" shelf const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5) if (seriesMostRecentPayload.series.length) { shelves.push({ id: 'recent-series', label: 'Recent Series', labelStringKey: 'LabelRecentSeries', type: 'series', entities: seriesMostRecentPayload.series, total: seriesMostRecentPayload.count }) } Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) start = Date.now() // "Discover" shelf const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, user, include, limit) if (discoverLibraryItemsPayload.libraryItems.length) { shelves.push({ id: 'discover', label: 'Discover', labelStringKey: 'LabelDiscover', type: library.mediaType, entities: discoverLibraryItemsPayload.libraryItems, total: discoverLibraryItemsPayload.count }) } Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } start = Date.now() // "Listen Again" shelf const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit) if (mediaFinishedPayload.items.length) { const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.isEBookOnly) const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => !li.media.isEBookOnly) shelves.push({ id: 'listen-again', label: 'Listen Again', labelStringKey: 'LabelListenAgain', type: library.isPodcast ? 'episode' : 'book', entities: audioOnlyItemsInProgress, total: mediaFinishedPayload.count }) // "Read Again" shelf if (ebookOnlyItemsInProgress.length) { shelves.push({ id: 'read-again', label: 'Read Again', labelStringKey: 'LabelReadAgain', type: 'book', entities: ebookOnlyItemsInProgress, total: mediaFinishedPayload.count }) } } Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) if (library.isBook) { start = Date.now() // "Newest Authors" shelf const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, user, limit) if (newestAuthorsPayload.authors.length) { shelves.push({ id: 'newest-authors', label: 'Newest Authors', labelStringKey: 'LabelNewestAuthors', type: 'authors', entities: newestAuthorsPayload.authors, total: newestAuthorsPayload.count }) } Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) return shelves } /** * Get book library items for author, optional use user permissions * @param {oldAuthor} author * @param {import('./User')} user * @returns {Promise} */ static async getForAuthor(author, user = null) { const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined) return libraryItems.map((li) => this.getOldLibraryItem(li)) } /** * Get book library items in a collection * @param {oldCollection} collection * @returns {Promise} */ static async getForCollection(collection) { const libraryItems = await libraryFilters.getLibraryItemsForCollection(collection) return libraryItems.map((li) => this.getOldLibraryItem(li)) } /** * Check if library item exists * @param {string} libraryItemId * @returns {Promise} */ static async checkExistsById(libraryItemId) { return (await this.count({ where: { id: libraryItemId } })) > 0 } /** * * @param {import('sequelize').WhereOptions} where * @param {import('sequelize').BindOrReplacements} replacements * @returns {Object} oldLibraryItem */ static async findOneOld(where, replacements = {}) { const libraryItem = await this.findOne({ where, replacements, include: [ { model: this.sequelize.models.book, include: [ { model: this.sequelize.models.author, through: { attributes: [] } }, { model: this.sequelize.models.series, through: { attributes: ['sequence'] } } ] }, { model: this.sequelize.models.podcast, include: [ { model: this.sequelize.models.podcastEpisode } ] } ], order: [ [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] ] }) if (!libraryItem) return null return this.getOldLibraryItem(libraryItem) } /** * * @param {import('sequelize').FindOptions} options * @returns {Promise} */ getMedia(options) { if (!this.mediaType) return Promise.resolve(null) const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}` return this[mixinMethodName](options) } /** * * @returns {Promise} */ getMediaExpanded() { if (this.mediaType === 'podcast') { return this.getMedia({ include: [ { model: this.sequelize.models.podcastEpisode } ] }) } else { return this.getMedia({ include: [ { model: this.sequelize.models.author, through: { attributes: [] } }, { model: this.sequelize.models.series, through: { attributes: ['sequence'] } } ], order: [ [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] ] }) } } /** * * @returns {Promise} */ async saveMetadataFile() { let metadataPath = Path.join(global.MetadataPath, 'items', this.id) let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem if (storeMetadataWithItem && !this.isFile) { metadataPath = this.path } else { // Make sure metadata book dir exists storeMetadataWithItem = false await fsExtra.ensureDir(metadataPath) } const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) // Expanded with series, authors, podcastEpisodes const mediaExpanded = this.media || (await this.getMediaExpanded()) let jsonObject = {} if (this.mediaType === 'book') { jsonObject = { tags: mediaExpanded.tags || [], chapters: mediaExpanded.chapters?.map((c) => ({ ...c })) || [], title: mediaExpanded.title, subtitle: mediaExpanded.subtitle, authors: mediaExpanded.authors.map((a) => a.name), narrators: mediaExpanded.narrators, series: mediaExpanded.series.map((se) => { const sequence = se.bookSeries?.sequence || '' if (!sequence) return se.name return `${se.name} #${sequence}` }), genres: mediaExpanded.genres || [], publishedYear: mediaExpanded.publishedYear, publishedDate: mediaExpanded.publishedDate, publisher: mediaExpanded.publisher, description: mediaExpanded.description, isbn: mediaExpanded.isbn, asin: mediaExpanded.asin, language: mediaExpanded.language, explicit: !!mediaExpanded.explicit, abridged: !!mediaExpanded.abridged } } else { jsonObject = { tags: mediaExpanded.tags || [], title: mediaExpanded.title, author: mediaExpanded.author, description: mediaExpanded.description, releaseDate: mediaExpanded.releaseDate, genres: mediaExpanded.genres || [], feedURL: mediaExpanded.feedURL, imageURL: mediaExpanded.imageURL, itunesPageURL: mediaExpanded.itunesPageURL, itunesId: mediaExpanded.itunesId, itunesArtistId: mediaExpanded.itunesArtistId, asin: mediaExpanded.asin, language: mediaExpanded.language, explicit: !!mediaExpanded.explicit, podcastType: mediaExpanded.podcastType } } return fsExtra .writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)) .then(async () => { // Add metadata.json to libraryFiles array if it is new let metadataLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath)) if (storeMetadataWithItem) { if (!metadataLibraryFile) { const newLibraryFile = new LibraryFile() await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) metadataLibraryFile = newLibraryFile.toJSON() this.libraryFiles.push(metadataLibraryFile) } else { const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) if (fileTimestamps) { metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs metadataLibraryFile.metadata.size = fileTimestamps.size metadataLibraryFile.ino = fileTimestamps.ino } } const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) if (libraryItemDirTimestamps) { this.mtime = libraryItemDirTimestamps.mtimeMs this.ctime = libraryItemDirTimestamps.ctimeMs let size = 0 this.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) this.size = size await this.save() } } Logger.debug(`Success saving abmetadata to "${metadataFilePath}"`) return metadataLibraryFile }) .catch((error) => { Logger.error(`Failed to save json file at "${metadataFilePath}"`, error) return null }) } /** * Initialize model * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { super.init( { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, ino: DataTypes.STRING, path: DataTypes.STRING, relPath: DataTypes.STRING, mediaId: DataTypes.UUIDV4, mediaType: DataTypes.STRING, isFile: DataTypes.BOOLEAN, isMissing: DataTypes.BOOLEAN, isInvalid: DataTypes.BOOLEAN, mtime: DataTypes.DATE(6), ctime: DataTypes.DATE(6), birthtime: DataTypes.DATE(6), size: DataTypes.BIGINT, lastScan: DataTypes.DATE, lastScanVersion: DataTypes.STRING, libraryFiles: DataTypes.JSON, extraData: DataTypes.JSON }, { sequelize, modelName: 'libraryItem', indexes: [ { fields: ['createdAt'] }, { fields: ['mediaId'] }, { fields: ['libraryId', 'mediaType'] }, { fields: ['libraryId', 'mediaId', 'mediaType'] }, { fields: ['birthtime'] }, { fields: ['mtime'] } ] } ) const { library, libraryFolder, book, podcast } = sequelize.models library.hasMany(LibraryItem) LibraryItem.belongsTo(library) libraryFolder.hasMany(LibraryItem) LibraryItem.belongsTo(libraryFolder) book.hasOne(LibraryItem, { foreignKey: 'mediaId', constraints: false, scope: { mediaType: 'book' } }) LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false }) podcast.hasOne(LibraryItem, { foreignKey: 'mediaId', constraints: false, scope: { mediaType: 'podcast' } }) LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false }) LibraryItem.addHook('afterFind', (findResult) => { if (!findResult) return if (!Array.isArray(findResult)) findResult = [findResult] for (const instance of findResult) { if (instance.mediaType === 'book' && instance.book !== undefined) { instance.media = instance.book instance.dataValues.media = instance.dataValues.book } else if (instance.mediaType === 'podcast' && instance.podcast !== undefined) { instance.media = instance.podcast instance.dataValues.media = instance.dataValues.podcast } // To prevent mistakes: delete instance.book delete instance.dataValues.book delete instance.podcast delete instance.dataValues.podcast } }) LibraryItem.addHook('afterDestroy', async (instance) => { if (!instance) return const media = await instance.getMedia() if (media) { media.destroy() } }) } } module.exports = LibraryItem