diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index cac9a7d17d..fad3252dc3 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -2,6 +2,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') const authorFilters = require('./authorFilters') +const { isFileSearchQuery, getFileMatchIds } = require('./searchFileHelpers') const ShareManager = require('../../managers/ShareManager') const { profile } = require('../profiler') @@ -1098,6 +1099,9 @@ module.exports = { const matchTitle = textSearchQuery.matchExpression('book.title') const matchSubtitle = textSearchQuery.matchExpression('book.subtitle') + // When the query looks like a file name, also match library items by file path or folder path + const fileMatchIds = isFileSearchQuery(query) ? await getFileMatchIds(library.id, textSearchQuery) : [] + // Search title, subtitle, asin, isbn const books = await Database.bookModel.findAll({ where: [ @@ -1114,7 +1118,8 @@ module.exports = { isbn: { [Sequelize.Op.substring]: query } - } + }, + ...(fileMatchIds.length ? [{ '$libraryItem.id$': { [Sequelize.Op.in]: fileMatchIds } }] : []) ] }, ...userPermissionBookWhere.bookWhere diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 8bb5dc110c..3c9b2ff68a 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -2,6 +2,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') const { profile } = require('../../utils/profiler') +const { isFileSearchQuery, getFileMatchIds } = require('./searchFileHelpers') const stringifySequelizeQuery = require('../stringifySequelizeQuery') const countCache = new Map() @@ -366,6 +367,48 @@ module.exports = { const matchTitle = textSearchQuery.matchExpression('podcast.title') const matchAuthor = textSearchQuery.matchExpression('podcast.author') + // When the query looks like a file name, also match library items by file path or folder path. + // Episode file matches are attributed to the parent podcast so the podcast is returned. + let fileMatchIds = [] + if (isFileSearchQuery(query)) { + const matchedItemIds = await getFileMatchIds(library.id, textSearchQuery) + if (matchedItemIds.length) { + fileMatchIds.push(...matchedItemIds) + const matchedEpisodeItems = await Database.libraryItemModel.findAll({ + attributes: ['mediaId'], + where: { + mediaType: 'episode', + id: { + [Sequelize.Op.in]: matchedItemIds + } + } + }) + if (matchedEpisodeItems.length) { + const matchedEpisodes = await Database.podcastEpisodeModel.findAll({ + attributes: ['podcastId'], + where: { + id: { + [Sequelize.Op.in]: matchedEpisodeItems.map((episodeItem) => episodeItem.mediaId) + } + } + }) + if (matchedEpisodes.length) { + const episodePodcastItems = await Database.libraryItemModel.findAll({ + attributes: ['id'], + where: { + mediaType: 'podcast', + mediaId: { + [Sequelize.Op.in]: matchedEpisodes.map((episode) => episode.podcastId) + } + } + }) + fileMatchIds.push(...episodePodcastItems.map((li) => li.id)) + } + } + fileMatchIds = [...new Set(fileMatchIds)] + } + } + // Search title, author, itunesId, itunesArtistId const podcasts = await Database.podcastModel.findAll({ where: [ @@ -382,7 +425,8 @@ module.exports = { itunesArtistId: { [Sequelize.Op.substring]: query } - } + }, + ...(fileMatchIds.length ? [{ '$libraryItem.id$': { [Sequelize.Op.in]: fileMatchIds } }] : []) ] }, ...userPermissionPodcastWhere.podcastWhere diff --git a/server/utils/queries/searchFileHelpers.js b/server/utils/queries/searchFileHelpers.js new file mode 100644 index 0000000000..2ed5a585f5 --- /dev/null +++ b/server/utils/queries/searchFileHelpers.js @@ -0,0 +1,56 @@ +const globals = require('../globals') +const Database = require('../../Database') + +/** + * Union of supported audio and ebook file extensions, without leading dot + */ +const fileSearchExtensions = [...globals.SupportedAudioTypes, ...globals.SupportedEbookTypes] + +/** + * Maximum number of library item ids returned by getFileMatchIds + */ +const maxFileMatchIds = 1000 + +/** + * Check if a search query looks like a file name, i.e. ends with a known audio or ebook extension + * @param {string} query + * @returns {boolean} + */ +function isFileSearchQuery(query) { + if (!query || typeof query !== 'string') return false + const trimmedQuery = query.trim().toLowerCase() + const dotIndex = trimmedQuery.lastIndexOf('.') + if (dotIndex < 0) return false + return fileSearchExtensions.includes(trimmedQuery.slice(dotIndex + 1)) +} + +/** + * Get ids of library items in the library that have a file path or folder path matching the text search query + * @param {string} libraryId + * @param {TextSearchQuery} textSearchQuery + * @returns {Promise} + */ +async function getFileMatchIds(libraryId, textSearchQuery) { + const matchFilePath = textSearchQuery.matchExpression(`json_extract(lf.value, '$.metadata.path')`) + const [fileRows] = await Database.sequelize.query(`SELECT DISTINCT li.id FROM libraryItems li, json_each(li.libraryFiles) lf WHERE json_valid(li.libraryFiles) AND li.libraryId = :libraryId AND ${matchFilePath} LIMIT ${maxFileMatchIds};`, { + replacements: { + libraryId + }, + raw: true + }) + + const matchItemPath = textSearchQuery.matchExpression('path') + const [pathRows] = await Database.sequelize.query(`SELECT id FROM libraryItems WHERE libraryId = :libraryId AND ${matchItemPath} LIMIT ${maxFileMatchIds};`, { + replacements: { + libraryId + }, + raw: true + }) + + return [...new Set([...fileRows.map((row) => row.id), ...pathRows.map((row) => row.id)])] +} + +module.exports = { + isFileSearchQuery, + getFileMatchIds +} diff --git a/test/server/utils/queries/libraryItemsBookFilters.search.test.js b/test/server/utils/queries/libraryItemsBookFilters.search.test.js new file mode 100644 index 0000000000..77a7f87039 --- /dev/null +++ b/test/server/utils/queries/libraryItemsBookFilters.search.test.js @@ -0,0 +1,85 @@ +const chai = require('chai') +const { expect } = chai +const { Sequelize } = require('sequelize') + +const Database = require('../../../../server/Database') +const libraryItemsBookFilters = require('../../../../server/utils/queries/libraryItemsBookFilters') + +describe('libraryItemsBookFilters search', () => { + let library + let folder + let user + + async function createBookItem(title, path, libraryFiles, explicit = false) { + const book = await Database.bookModel.create({ title, explicit, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + return Database.libraryItemModel.create({ + mediaType: 'book', + mediaId: book.id, + libraryId: library.id, + libraryFolderId: folder.id, + path, + libraryFiles + }) + } + + beforeEach(async () => { + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + global.ServerSettings = { sortingPrefixes: [], titlePostfixExclusions: [] } + await Database.buildModels() + await Database.sequelize.sync({ force: true }) + + library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + folder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) + user = { canAccessExplicitContent: true, permissions: { accessAllTags: true } } + + // Matches title and file for query "lalalalala.mp3" + await createBookItem('Lalalalala Songbook', '/data/books/lala', [{ metadata: { path: '/data/books/lala/lalalalala.mp3', filename: 'lalalalala.mp3', ext: '.mp3' } }]) + // File-only match for query "track.mp3" + await createBookItem('Silent Item', '/data/books/other', [{ metadata: { path: '/data/books/other/hidden-track.mp3', filename: 'hidden-track.mp3', ext: '.mp3' } }]) + // No files at all + await createBookItem('No Files Book', '/data/books/nofiles', []) + // Matches title and file for query "record.mp3" (dedup test) + await createBookItem('record.mp3 Live Session', '/data/books/rec', [{ metadata: { path: '/data/books/rec/record.mp3', filename: 'record.mp3', ext: '.mp3' } }]) + // Explicit, file match for query "explicit.mp3" + await createBookItem('Explicit Book', '/data/books/explicit', [{ metadata: { path: '/data/books/explicit/explicit.mp3', filename: 'explicit.mp3', ext: '.mp3' } }], true) + }) + + it('returns items matched only by file path in the book bucket', async () => { + const results = await libraryItemsBookFilters.search(user, library, 'track.mp3', 12, 0) + const titles = results.book.map((r) => r.libraryItem.media.metadata.title) + expect(titles).to.deep.equal(['Silent Item']) + }) + + it('returns an item matching both title and file path exactly once', async () => { + const results = await libraryItemsBookFilters.search(user, library, 'record.mp3', 12, 0) + const titles = results.book.map((r) => r.libraryItem.media.metadata.title) + expect(titles).to.deep.equal(['record.mp3 Live Session']) + }) + + it('does not change results for metadata-only queries', async () => { + const results = await libraryItemsBookFilters.search(user, library, 'Book', 12, 0) + const titles = results.book.map((r) => r.libraryItem.media.metadata.title).sort() + // Title matches only: file-only matches (Silent Item) must not leak in + expect(titles).to.deep.equal(['Explicit Book', 'Lalalalala Songbook', 'No Files Book']) + }) + + it('excludes file matches the user cannot access', async () => { + const restrictedUser = { canAccessExplicitContent: false, permissions: { accessAllTags: true } } + const results = await libraryItemsBookFilters.search(restrictedUser, library, 'explicit.mp3', 12, 0) + expect(results.book).to.deep.equal([]) + }) + + it('returns empty buckets when a file-shape query matches nothing', async () => { + const results = await libraryItemsBookFilters.search(user, library, 'nomatch.mp3', 12, 0) + expect(results.book).to.deep.equal([]) + expect(results.series).to.deep.equal([]) + expect(results.authors).to.deep.equal([]) + }) + + it('returns all items with a matching extension for a bare extension query', async () => { + const results = await libraryItemsBookFilters.search(user, library, '.mp3', 12, 0) + // All items except "No Files Book" have an .mp3 file + expect(results.book.length).to.equal(4) + }) +}) diff --git a/test/server/utils/queries/libraryItemsPodcastFilters.search.test.js b/test/server/utils/queries/libraryItemsPodcastFilters.search.test.js new file mode 100644 index 0000000000..fc48d2516e --- /dev/null +++ b/test/server/utils/queries/libraryItemsPodcastFilters.search.test.js @@ -0,0 +1,75 @@ +const chai = require('chai') +const { expect } = chai +const { Sequelize } = require('sequelize') + +const Database = require('../../../../server/Database') +const libraryItemsPodcastFilters = require('../../../../server/utils/queries/libraryItemsPodcastFilters') + +describe('libraryItemsPodcastFilters search', () => { + let library + let folder + let user + + async function createPodcast(title, episodes) { + const podcast = await Database.podcastModel.create({ title }) + await Database.libraryItemModel.create({ + mediaType: 'podcast', + mediaId: podcast.id, + libraryId: library.id, + libraryFolderId: folder.id, + path: `/data/podcasts/${title}`, + libraryFiles: [] + }) + for (const episode of episodes) { + const ep = await Database.podcastEpisodeModel.create({ podcastId: podcast.id, title: episode.title }) + await Database.libraryItemModel.create({ + mediaType: 'episode', + mediaId: ep.id, + libraryId: library.id, + libraryFolderId: folder.id, + path: `/data/podcasts/${title}/${episode.filename}`, + libraryFiles: [{ metadata: { path: `/data/podcasts/${title}/${episode.filename}`, filename: episode.filename, ext: `.${episode.filename.split('.').pop()}` } }] + }) + } + return podcast + } + + beforeEach(async () => { + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + global.ServerSettings = { sortingPrefixes: [], titlePostfixExclusions: [] } + await Database.buildModels() + await Database.sequelize.sync({ force: true }) + + library = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' }) + folder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) + user = { canAccessExplicitContent: true, permissions: { accessAllTags: true } } + + await createPodcast('Test Podcast', [{ title: 'Episode One', filename: 'ep-one.mp3' }]) + await createPodcast('Other Show', [{ title: 'Episode Two', filename: 'episode-two.m4a' }]) + }) + + it('returns the parent podcast when an episode file path matches', async () => { + const results = await libraryItemsPodcastFilters.search(user, library, 'ep-one.mp3', 12, 0) + const titles = results.podcast.map((r) => r.libraryItem.media.metadata.title) + expect(titles).to.deep.equal(['Test Podcast']) + }) + + it('returns a podcast matching title and episode file exactly once', async () => { + await createPodcast('ep-one.mp3 Favorites', [{ title: 'Rerun', filename: 'rerun-ep-one.mp3' }]) + const results = await libraryItemsPodcastFilters.search(user, library, 'ep-one.mp3', 12, 0) + const titles = results.podcast.map((r) => r.libraryItem.media.metadata.title) + expect(titles.filter((t) => t === 'ep-one.mp3 Favorites').length).to.equal(1) + }) + + it('does not change results for metadata-only queries', async () => { + const results = await libraryItemsPodcastFilters.search(user, library, 'Show', 12, 0) + const titles = results.podcast.map((r) => r.libraryItem.media.metadata.title) + expect(titles).to.deep.equal(['Other Show']) + }) + + it('returns empty podcast bucket when a file-shape query matches nothing', async () => { + const results = await libraryItemsPodcastFilters.search(user, library, 'nomatch.mp3', 12, 0) + expect(results.podcast).to.deep.equal([]) + }) +}) diff --git a/test/server/utils/queries/searchFileHelpers.test.js b/test/server/utils/queries/searchFileHelpers.test.js new file mode 100644 index 0000000000..3cd056eb2b --- /dev/null +++ b/test/server/utils/queries/searchFileHelpers.test.js @@ -0,0 +1,133 @@ +const chai = require('chai') +const { expect } = chai +const { Sequelize } = require('sequelize') + +const Database = require('../../../../server/Database') +const { isFileSearchQuery, getFileMatchIds } = require('../../../../server/utils/queries/searchFileHelpers') + +describe('searchFileHelpers', () => { + describe('isFileSearchQuery', () => { + it('returns true for queries ending with a supported audio extension', () => { + expect(isFileSearchQuery('lalalalala.mp3')).to.equal(true) + expect(isFileSearchQuery('album.m4b')).to.equal(true) + expect(isFileSearchQuery('track.flac')).to.equal(true) + }) + + it('returns true for queries ending with a supported ebook extension', () => { + expect(isFileSearchQuery('dune.epub')).to.equal(true) + expect(isFileSearchQuery('comic.cbz')).to.equal(true) + }) + + it('returns true for uppercase extensions', () => { + expect(isFileSearchQuery('lalalalala.MP3')).to.equal(true) + expect(isFileSearchQuery('Dune.EPUB')).to.equal(true) + }) + + it('returns true for a bare extension', () => { + expect(isFileSearchQuery('.mp3')).to.equal(true) + }) + + it('trims leading and trailing whitespace', () => { + expect(isFileSearchQuery(' lalalalala.mp3 ')).to.equal(true) + }) + + it('handles unicode in the query', () => { + expect(isFileSearchQuery('café.flac')).to.equal(true) + }) + + it('returns false for queries without a known extension', () => { + expect(isFileSearchQuery('dune')).to.equal(false) + expect(isFileSearchQuery('hotel.california')).to.equal(false) + expect(isFileSearchQuery('mp3')).to.equal(false) + expect(isFileSearchQuery('file.xyz')).to.equal(false) + }) + + it('returns false for empty and non-string input', () => { + expect(isFileSearchQuery('')).to.equal(false) + expect(isFileSearchQuery(null)).to.equal(false) + expect(isFileSearchQuery(undefined)).to.equal(false) + expect(isFileSearchQuery(42)).to.equal(false) + }) + }) + + describe('getFileMatchIds', () => { + let library + let folder + let itemWithFile + let itemFolderOnly + let itemOtherFile + let itemInvalidJson + + async function createBookItem(path, libraryFiles) { + const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + return Database.libraryItemModel.create({ + mediaType: 'book', + mediaId: book.id, + libraryId: library.id, + libraryFolderId: folder.id, + path, + libraryFiles + }) + } + + beforeEach(async () => { + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + await Database.sequelize.sync({ force: true }) + + library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + folder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) + + itemWithFile = await createBookItem('/data/books/artist/album', [{ metadata: { path: '/data/books/artist/album/lalalalala.mp3', filename: 'lalalalala.mp3', ext: '.mp3' } }]) + itemFolderOnly = await createBookItem('/data/books/folderonly', []) + itemOtherFile = await createBookItem('/data/books/other', [{ metadata: { path: '/data/books/other/x.flac', filename: 'x.flac', ext: '.flac' } }]) + itemInvalidJson = await createBookItem('/data/books/broken', []) + await Database.sequelize.query(`UPDATE libraryItems SET libraryFiles = 'not json' WHERE id = '${itemInvalidJson.id}'`) + }) + + it('matches by file name', async () => { + const textSearchQuery = await Database.createTextSearchQuery('lalalalala.mp3') + const ids = await getFileMatchIds(library.id, textSearchQuery) + expect(ids).to.deep.equal([itemWithFile.id]) + }) + + it('matches case-insensitively', async () => { + const textSearchQuery = await Database.createTextSearchQuery('LALALALALA.MP3') + const ids = await getFileMatchIds(library.id, textSearchQuery) + expect(ids).to.deep.equal([itemWithFile.id]) + }) + + it('matches by full pathname', async () => { + const textSearchQuery = await Database.createTextSearchQuery('artist/album/lalalalala.mp3') + const ids = await getFileMatchIds(library.id, textSearchQuery) + expect(ids).to.deep.equal([itemWithFile.id]) + }) + + it('matches by item folder path when no file path matches', async () => { + const textSearchQuery = await Database.createTextSearchQuery('folderonly') + const ids = await getFileMatchIds(library.id, textSearchQuery) + expect(ids).to.deep.equal([itemFolderOnly.id]) + }) + + it('returns no ids when nothing matches', async () => { + const textSearchQuery = await Database.createTextSearchQuery('nomatch.mp3') + const ids = await getFileMatchIds(library.id, textSearchQuery) + expect(ids).to.deep.equal([]) + }) + + it('ignores items with invalid libraryFiles JSON without throwing', async () => { + const textSearchQuery = await Database.createTextSearchQuery('lalalalala.mp3') + const ids = await getFileMatchIds(library.id, textSearchQuery) + expect(ids).to.not.include(itemInvalidJson.id) + expect(ids).to.include(itemWithFile.id) + }) + + it('only matches items in the given library', async () => { + const otherLibrary = await Database.libraryModel.create({ name: 'Other Library', mediaType: 'book' }) + const textSearchQuery = await Database.createTextSearchQuery('lalalalala.mp3') + const ids = await getFileMatchIds(otherLibrary.id, textSearchQuery) + expect(ids).to.deep.equal([]) + }) + }) +})