From c77cead9ae01f0e1e72b7d03574a2160df94b55b Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 13:59:22 -0500 Subject: [PATCH] Update search endpoints to search db directly --- client/components/cards/NarratorCard.vue | 2 +- client/components/controls/GlobalSearch.vue | 2 +- server/Database.js | 15 ++ server/controllers/LibraryController.js | 84 ++------ server/routers/ApiRouter.js | 2 +- server/utils/queries/libraryItemFilters.js | 19 +- .../utils/queries/libraryItemsBookFilters.js | 199 +++++++++++++++++- .../queries/libraryItemsPodcastFilters.js | 99 +++++++++ 8 files changed, 344 insertions(+), 78 deletions(-) diff --git a/client/components/cards/NarratorCard.vue b/client/components/cards/NarratorCard.vue index 7b8848ccab..e1b4840f5c 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 a731dbcf57..0c5ba41b43 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/server/Database.js b/server/Database.js index 303e2d4ac3..faec70b8a4 100644 --- a/server/Database.js +++ b/server/Database.js @@ -44,6 +44,21 @@ class Database { return this.models.series } + /** @type {typeof import('./models/Book')} */ + get bookModel() { + return this.models.book + } + + /** @type {typeof import('./models/Podcast')} */ + get podcastModel() { + return this.models.podcast + } + + /** @type {typeof import('./models/LibraryItem')} */ + get libraryItemModel() { + return this.models.libraryItem + } + async checkHasDb() { if (!await fs.pathExists(this.dbPath)) { Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index b9bb58bbb9..97f9c9819b 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -790,80 +790,22 @@ class LibraryController { }) } - // GET: Global library search - search(req, res) { + /** + * GET: /api/libraries/:id/search + * Search library items with query + * ?q=search + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async search(req, res) { if (!req.query.q) { return res.status(400).send('No query string') } - const libraryItems = req.libraryItems - const maxResults = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 - - const itemMatches = [] - const authorMatches = {} - const narratorMatches = {} - const seriesMatches = {} - const tagMatches = {} - - libraryItems.forEach((li) => { - const queryResult = li.searchQuery(req.query.q) - if (queryResult.matchKey) { - itemMatches.push({ - libraryItem: li.toJSONExpanded(), - matchKey: queryResult.matchKey, - matchText: queryResult.matchText - }) - } - if (queryResult.series?.length) { - queryResult.series.forEach((se) => { - if (!seriesMatches[se.id]) { - const _series = Database.series.find(_se => _se.id === se.id) - if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] } - } else { - seriesMatches[se.id].books.push(li.toJSON()) - } - }) - } - if (queryResult.authors?.length) { - queryResult.authors.forEach((au) => { - if (!authorMatches[au.id]) { - const _author = Database.authors.find(_au => _au.id === au.id) - if (_author) { - authorMatches[au.id] = _author.toJSON() - authorMatches[au.id].numBooks = 1 - } - } else { - authorMatches[au.id].numBooks++ - } - }) - } - if (queryResult.tags?.length) { - queryResult.tags.forEach((tag) => { - if (!tagMatches[tag]) { - tagMatches[tag] = { name: tag, books: [li.toJSON()] } - } else { - tagMatches[tag].books.push(li.toJSON()) - } - }) - } - if (queryResult.narrators?.length) { - queryResult.narrators.forEach((narrator) => { - if (!narratorMatches[narrator]) { - narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] } - } else { - narratorMatches[narrator].books.push(li.toJSON()) - } - }) - } - }) - const itemKey = req.library.mediaType - const results = { - [itemKey]: itemMatches.slice(0, maxResults), - tags: Object.values(tagMatches).slice(0, maxResults), - authors: Object.values(authorMatches).slice(0, maxResults), - series: Object.values(seriesMatches).slice(0, maxResults), - narrators: Object.values(narratorMatches).slice(0, maxResults) - } - res.json(results) + const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 + const query = req.query.q.trim().toLowerCase() + + const matches = await libraryItemFilters.search(req.library, query, limit) + res.json(matches) } async stats(req, res) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 8b5138b077..c0ff10a0e7 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -86,7 +86,7 @@ class ApiRouter { this.router.get('/libraries/:id/personalized2', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this)) this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this)) this.router.get('/libraries/:id/filterdata', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryFilterData.bind(this)) - this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this)) + this.router.get('/libraries/:id/search', LibraryController.middlewareNew.bind(this), LibraryController.search.bind(this)) this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) this.router.get('/libraries/:id/authors', LibraryController.middlewareNew.bind(this), LibraryController.getAuthors.bind(this)) this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this)) diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js index b20916ca87..51bb4131a6 100644 --- a/server/utils/queries/libraryItemFilters.js +++ b/server/utils/queries/libraryItemFilters.js @@ -1,5 +1,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') +const libraryItemsBookFilters = require('./libraryItemsBookFilters') +const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters') module.exports = { /** @@ -127,7 +129,7 @@ module.exports = { /** * Get all library items that have narrators * @param {string[]} narrators - * @returns {Promise} + * @returns {Promise} */ async getAllLibraryItemsWithNarrators(narrators) { const libraryItems = [] @@ -162,5 +164,20 @@ module.exports = { libraryItems.push(libraryItem) } return libraryItems + }, + + /** + * Search library items + * @param {import('../../objects/Library')} oldLibrary + * @param {string} query + * @param {number} limit + * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}} + */ + search(oldLibrary, query, limit) { + if (oldLibrary.isBook) { + return libraryItemsBookFilters.search(oldLibrary, query, limit, 0) + } else { + return libraryItemsPodcastFilters.search(oldLibrary, query, limit, 0) + } } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 2385edf9be..21ef5efa3e 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -918,12 +918,205 @@ module.exports = { /** * Get library items for series - * @param {oldSeries} oldSeries - * @param {[oldUser]} oldUser - * @returns {Promise} + * @param {import('../../objects/entities/Series')} oldSeries + * @param {import('../../objects/user/User')} [oldUser] + * @returns {Promise} */ async getLibraryItemsForSeries(oldSeries, oldUser) { const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, oldUser, 'series', oldSeries.id, null, null, false, [], null, null) return libraryItems.map(li => Database.models.libraryItem.getOldLibraryItem(li)) + }, + + /** + * Search books, authors, series + * @param {import('../../objects/Library')} oldLibrary + * @param {string} query + * @param {number} limit + * @param {number} offset + * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}} + */ + async search(oldLibrary, query, limit, offset) { + // Search title, subtitle, asin, isbn + const books = await Database.bookModel.findAll({ + where: { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + subtitle: { + [Sequelize.Op.substring]: query + } + }, + { + asin: { + [Sequelize.Op.substring]: query + } + }, + { + isbn: { + [Sequelize.Op.substring]: query + } + } + ] + }, + include: [ + { + model: Database.libraryItemModel, + where: { + libraryId: oldLibrary.id + } + }, + { + model: Database.models.bookSeries, + include: { + model: Database.seriesModel + }, + separate: true + }, + { + model: Database.models.bookAuthor, + include: { + model: Database.authorModel + }, + separate: true + } + ], + subQuery: false, + distinct: true, + limit, + offset + }) + + const itemMatches = [] + + for (const book of books) { + const libraryItem = book.libraryItem + delete book.libraryItem + libraryItem.media = book + + let matchText = null + let matchKey = null + for (const key of ['title', 'subtitle', 'asin', 'isbn']) { + if (book[key]?.toLowerCase().includes(query)) { + matchText = book[key] + matchKey = key + break + } + } + + if (matchKey) { + itemMatches.push({ + matchText, + matchKey, + libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() + }) + } + } + + // Search narrators + const narratorMatches = [] + const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { + replacements: { + query: `%${query}%`, + libraryId: oldLibrary.id, + limit, + offset + }, + raw: true + }) + for (const row of narratorResults) { + narratorMatches.push({ + name: row.value, + numBooks: row.numBooks + }) + } + + // Search tags + const tagMatches = [] + const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { + replacements: { + query: `%${query}%`, + libraryId: oldLibrary.id, + limit, + offset + }, + raw: true + }) + for (const row of tagResults) { + tagMatches.push({ + name: row.value, + numItems: row.numItems + }) + } + + // Search series + const allSeries = await Database.seriesModel.findAll({ + where: { + name: { + [Sequelize.Op.substring]: query + }, + libraryId: oldLibrary.id + }, + include: { + separate: true, + model: Database.models.bookSeries, + include: { + model: Database.bookModel, + include: { + model: Database.libraryItemModel + } + } + }, + subQuery: false, + distinct: true, + limit, + offset + }) + const seriesMatches = [] + for (const series of allSeries) { + const books = series.bookSeries.map((bs) => { + const libraryItem = bs.book.libraryItem + libraryItem.media = bs.book + return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON() + }) + seriesMatches.push({ + series: series.getOldSeries().toJSON(), + books + }) + } + + // Search authors + const authors = await Database.authorModel.findAll({ + where: { + name: { + [Sequelize.Op.substring]: query + }, + libraryId: oldLibrary.id + }, + attributes: { + include: [ + [Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks'] + ] + }, + limit, + offset + }) + const authorMatches = [] + for (const author of authors) { + const oldAuthor = author.getOldAuthor().toJSON() + oldAuthor.numBooks = author.dataValues.numBooks + authorMatches.push(oldAuthor) + } + + return { + book: itemMatches, + narrators: narratorMatches, + tags: tagMatches, + series: seriesMatches, + authors: authorMatches + } } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 6462200b74..7dc87a60ea 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -291,5 +291,104 @@ module.exports = { libraryItems, count } + }, + + /** + * Search podcasts + * @param {import('../../objects/Library')} oldLibrary + * @param {string} query + * @param {number} limit + * @param {number} offset + * @returns {{podcast:object[], tags:object[]}} + */ + async search(oldLibrary, query, limit, offset) { + // Search title, author, itunesId, itunesArtistId + const podcasts = await Database.podcastModel.findAll({ + where: { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + author: { + [Sequelize.Op.substring]: query + } + }, + { + itunesId: { + [Sequelize.Op.substring]: query + } + }, + { + itunesArtistId: { + [Sequelize.Op.substring]: query + } + } + ] + }, + include: [ + { + model: Database.libraryItemModel, + where: { + libraryId: oldLibrary.id + } + } + ], + subQuery: false, + distinct: true, + limit, + offset + }) + + const itemMatches = [] + + for (const podcast of podcasts) { + const libraryItem = podcast.libraryItem + delete podcast.libraryItem + libraryItem.media = podcast + + let matchText = null + let matchKey = null + for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) { + if (podcast[key]?.toLowerCase().includes(query)) { + matchText = podcast[key] + matchKey = key + break + } + } + + if (matchKey) { + itemMatches.push({ + matchText, + matchKey, + libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() + }) + } + } + + // Search tags + const tagMatches = [] + const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND json_each.value LIKE :query AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { + replacements: { + query: `%${query}%`, + libraryId: oldLibrary.id, + limit, + offset + }, + raw: true + }) + for (const row of tagResults) { + tagMatches.push({ + name: row.value, + numItems: row.numItems + }) + } + + return { + podcast: itemMatches, + tags: tagMatches + } } } \ No newline at end of file