Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion server/utils/queries/libraryItemsBookFilters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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: [
Expand All @@ -1114,7 +1118,8 @@ module.exports = {
isbn: {
[Sequelize.Op.substring]: query
}
}
},
...(fileMatchIds.length ? [{ '$libraryItem.id$': { [Sequelize.Op.in]: fileMatchIds } }] : [])
]
},
...userPermissionBookWhere.bookWhere
Expand Down
46 changes: 45 additions & 1 deletion server/utils/queries/libraryItemsPodcastFilters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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: [
Expand All @@ -382,7 +425,8 @@ module.exports = {
itunesArtistId: {
[Sequelize.Op.substring]: query
}
}
},
...(fileMatchIds.length ? [{ '$libraryItem.id$': { [Sequelize.Op.in]: fileMatchIds } }] : [])
]
},
...userPermissionPodcastWhere.podcastWhere
Expand Down
56 changes: 56 additions & 0 deletions server/utils/queries/searchFileHelpers.js
Original file line number Diff line number Diff line change
@@ -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<string[]>}
*/
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
}
85 changes: 85 additions & 0 deletions test/server/utils/queries/libraryItemsBookFilters.search.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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([])
})
})
Loading