diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index 40793610..fbed11be 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -171,7 +171,7 @@ export default { }, async fetchCategories() { const categories = await this.$axios - .$get(`/api/libraries/${this.currentLibraryId}/personalized2?include=rssfeed,numEpisodesIncomplete`) + .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`) .then((data) => { return data }) diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 3bc98fde..259b31ae 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -628,6 +628,11 @@ export default { return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed }, async init(bookshelf) { + if (this.entityName === 'series') { + this.booksPerFetch = 50 + } else { + this.booksPerFetch = 100 + } this.checkUpdateSearchParams() this.initSizeData(bookshelf) diff --git a/client/components/cards/NarratorCard.vue b/client/components/cards/NarratorCard.vue index 7b8848cc..e1b4840f 100644 --- a/client/components/cards/NarratorCard.vue +++ b/client/components/cards/NarratorCard.vue @@ -36,7 +36,7 @@ export default { return this.narrator?.name || '' }, numBooks() { - return this.narrator?.books?.length || 0 + return this.narrator?.numBooks || this.narrator?.books?.length || 0 }, userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] diff --git a/client/components/controls/GlobalSearch.vue b/client/components/controls/GlobalSearch.vue index a731dbcf..0c5ba41b 100644 --- a/client/components/controls/GlobalSearch.vue +++ b/client/components/controls/GlobalSearch.vue @@ -103,7 +103,7 @@ export default { return this.$store.state.libraries.currentLibraryId }, totalResults() { - return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length } }, methods: { diff --git a/client/components/stats/PreviewIcons.vue b/client/components/stats/PreviewIcons.vue index 488eae49..7bda889b 100644 --- a/client/components/stats/PreviewIcons.vue +++ b/client/components/stats/PreviewIcons.vue @@ -18,7 +18,7 @@ -
{{ $strings.MessageNoAuthors }}
@@ -114,43 +114,49 @@ export default { return this.$store.state.user.user }, totalItems() { - return this.libraryStats ? this.libraryStats.totalItems : 0 + return this.libraryStats?.totalItems || 0 }, genresWithCount() { - return this.libraryStats ? this.libraryStats.genresWithCount : [] + return this.libraryStats?.genresWithCount || [] }, top5Genres() { - return this.genresWithCount.slice(0, 5) + return this.genresWithCount?.slice(0, 5) || [] }, top10LongestItems() { - return this.libraryStats ? this.libraryStats.longestItems || [] : [] + return this.libraryStats?.longestItems || [] }, longestItemDuration() { if (!this.top10LongestItems.length) return 0 return this.top10LongestItems[0].duration }, top10LargestItems() { - return this.libraryStats ? this.libraryStats.largestItems || [] : [] + return this.libraryStats?.largestItems || [] }, largestItemSize() { if (!this.top10LargestItems.length) return 0 return this.top10LargestItems[0].size }, authorsWithCount() { - return this.libraryStats ? this.libraryStats.authorsWithCount : [] + return this.libraryStats?.authorsWithCount || [] }, mostUsedAuthorCount() { if (!this.authorsWithCount.length) return 0 return this.authorsWithCount[0].count }, top10Authors() { - return this.authorsWithCount.slice(0, 10) + return this.authorsWithCount?.slice(0, 10) || [] }, currentLibraryId() { return this.$store.state.libraries.currentLibraryId }, currentLibraryName() { return this.$store.getters['libraries/getCurrentLibraryName'] + }, + currentLibraryMediaType() { + return this.$store.getters['libraries/getCurrentLibraryMediaType'] + }, + isBookLibrary() { + return this.currentLibraryMediaType === 'book' } }, methods: { diff --git a/client/store/libraries.js b/client/store/libraries.js index e0151626..fd8af4ae 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -234,6 +234,10 @@ export const mutations = { setNumUserPlaylists(state, numUserPlaylists) { state.numUserPlaylists = numUserPlaylists }, + removeSeriesFromFilterData(state, seriesId) { + if (!seriesId || !state.filterData) return + state.filterData.series = state.filterData.series.filter(se => se.id !== seriesId) + }, updateFilterDataWithItem(state, libraryItem) { if (!libraryItem || !state.filterData) return if (state.currentLibraryId !== libraryItem.libraryId) return diff --git a/server/Auth.js b/server/Auth.js index 37ea4bb1..6c7b9891 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -32,7 +32,7 @@ class Auth { await Database.updateServerSettings() // New token secret creation added in v2.1.0 so generate new API tokens for each user - const users = await Database.models.user.getOldUsers() + const users = await Database.userModel.getOldUsers() if (users.length) { for (const user of users) { user.token = await this.generateAccessToken({ userId: user.id, username: user.username }) @@ -100,7 +100,7 @@ class Auth { return resolve(null) } - const user = await Database.models.user.getUserByIdOrOldId(payload.userId) + const user = await Database.userModel.getUserByIdOrOldId(payload.userId) if (user && user.username === payload.username) { resolve(user) } else { @@ -116,7 +116,7 @@ class Auth { * @returns {object} */ async getUserLoginResponsePayload(user) { - const libraryIds = await Database.models.library.getAllLibraryIds() + const libraryIds = await Database.libraryModel.getAllLibraryIds() return { user: user.toJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), @@ -131,7 +131,7 @@ class Auth { const username = (req.body.username || '').toLowerCase() const password = req.body.password || '' - const user = await Database.models.user.getUserByUsername(username) + const user = await Database.userModel.getUserByUsername(username) if (!user?.isActive) { Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`) @@ -178,7 +178,7 @@ class Auth { async userChangePassword(req, res) { var { password, newPassword } = req.body newPassword = newPassword || '' - const matchingUser = await Database.models.user.getUserById(req.user.id) + const matchingUser = await Database.userModel.getUserById(req.user.id) // Only root can have an empty password if (matchingUser.type !== 'root' && !newPassword) { diff --git a/server/Database.js b/server/Database.js index ebe6d4c7..2ae3585e 100644 --- a/server/Database.js +++ b/server/Database.js @@ -34,6 +34,100 @@ class Database { return this.sequelize?.models || {} } + /** @type {typeof import('./models/User')} */ + get userModel() { + return this.models.user + } + + /** @type {typeof import('./models/Library')} */ + get libraryModel() { + return this.models.library + } + + /** @type {typeof import('./models/Author')} */ + get authorModel() { + return this.models.author + } + + /** @type {typeof import('./models/Series')} */ + get seriesModel() { + return this.models.series + } + + /** @type {typeof import('./models/Book')} */ + get bookModel() { + return this.models.book + } + + /** @type {typeof import('./models/BookSeries')} */ + get bookSeriesModel() { + return this.models.bookSeries + } + + /** @type {typeof import('./models/BookAuthor')} */ + get bookAuthorModel() { + return this.models.bookAuthor + } + + /** @type {typeof import('./models/Podcast')} */ + get podcastModel() { + return this.models.podcast + } + + /** @type {typeof import('./models/PodcastEpisode')} */ + get podcastEpisodeModel() { + return this.models.podcastEpisode + } + + /** @type {typeof import('./models/LibraryItem')} */ + get libraryItemModel() { + return this.models.libraryItem + } + + /** @type {typeof import('./models/PodcastEpisode')} */ + get podcastEpisodeModel() { + return this.models.podcastEpisode + } + + /** @type {typeof import('./models/MediaProgress')} */ + get mediaProgressModel() { + return this.models.mediaProgress + } + + /** @type {typeof import('./models/Collection')} */ + get collectionModel() { + return this.models.collection + } + + /** @type {typeof import('./models/CollectionBook')} */ + get collectionBookModel() { + return this.models.collectionBook + } + + /** @type {typeof import('./models/Playlist')} */ + get playlistModel() { + return this.models.playlist + } + + /** @type {typeof import('./models/PlaylistMediaItem')} */ + get playlistMediaItemModel() { + return this.models.playlistMediaItem + } + + /** @type {typeof import('./models/Feed')} */ + get feedModel() { + return this.models.feed + } + + /** @type {typeof import('./models/Feed')} */ + get feedEpisodeModel() { + return this.models.feedEpisode + } + + /** + * Check if db file exists + * @returns {boolean} + */ async checkHasDb() { if (!await fs.pathExists(this.dbPath)) { Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) @@ -42,6 +136,10 @@ class Database { return true } + /** + * Connect to db, build models and run migrations + * @param {boolean} [force=false] Used for testing, drops & re-creates all tables + */ async init(force = false) { this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite') @@ -58,6 +156,10 @@ class Database { await this.loadData() } + /** + * Connect to db + * @returns {boolean} + */ async connect() { Logger.info(`[Database] Initializing db at "${this.dbPath}"`) this.sequelize = new Sequelize({ @@ -80,12 +182,18 @@ class Database { } } + /** + * Disconnect from db + */ async disconnect() { Logger.info(`[Database] Disconnecting sqlite db`) await this.sequelize.close() this.sequelize = null } + /** + * Reconnect to db and init + */ async reconnect() { Logger.info(`[Database] Reconnecting sqlite db`) await this.init() @@ -481,6 +589,88 @@ class Database { } } } + + removeSeriesFromFilterData(libraryId, seriesId) { + if (!this.libraryFilterData[libraryId]) return + this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId) + } + + addSeriesToFilterData(libraryId, seriesName, seriesId) { + if (!this.libraryFilterData[libraryId]) return + // Check if series is already added + if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return + this.libraryFilterData[libraryId].series.push({ + id: seriesId, + name: seriesName + }) + } + + removeAuthorFromFilterData(libraryId, authorId) { + if (!this.libraryFilterData[libraryId]) return + this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId) + } + + addAuthorToFilterData(libraryId, authorName, authorId) { + if (!this.libraryFilterData[libraryId]) return + // Check if author is already added + if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return + this.libraryFilterData[libraryId].authors.push({ + id: authorId, + name: authorName + }) + } + + /** + * Used when updating items to make sure author id exists + * If library filter data is set then use that for check + * otherwise lookup in db + * @param {string} libraryId + * @param {string} authorId + * @returns {Promise