mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-08 02:11:35 +00:00
add postgres dialect support for homelab migration
This commit is contained in:
parent
fa5fa7b788
commit
453cd2587b
20 changed files with 851 additions and 159 deletions
|
|
@ -5,6 +5,7 @@ const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
|||
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
|
||||
const { createNewSortInstance } = require('../../libs/fastSort')
|
||||
const { profile } = require('../../utils/profiler')
|
||||
const { booleanLiteral, jsonArrayContainsAny } = require('../sqlDialectHelpers')
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
})
|
||||
|
|
@ -235,13 +236,13 @@ module.exports = {
|
|||
if (userPermissionBookWhere.bookWhere.length) {
|
||||
let attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id'
|
||||
if (!user.canAccessExplicitContent) {
|
||||
attrQuery += ' AND b.explicit = 0'
|
||||
attrQuery += ` AND b.explicit = ${booleanLiteral(false, Database.sequelize)}`
|
||||
}
|
||||
if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {
|
||||
if (user.permissions.selectedTagsNotAccessible) {
|
||||
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0'
|
||||
attrQuery += ` AND ${jsonArrayContainsAny('b.tags', 'userTagsSelected', Database.sequelize)} = 0`
|
||||
} else {
|
||||
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) > 0'
|
||||
attrQuery += ` AND ${jsonArrayContainsAny('b.tags', 'userTagsSelected', Database.sequelize)} > 0`
|
||||
}
|
||||
}
|
||||
seriesWhere.push(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const Sequelize = require('sequelize')
|
|||
const Database = require('../../Database')
|
||||
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
||||
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
|
||||
const { jsonArrayContainsAny } = require('../sqlDialectHelpers')
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
|
|
@ -12,7 +13,7 @@ module.exports = {
|
|||
async getAllLibraryItemsWithTags(tags) {
|
||||
const libraryItems = []
|
||||
const booksWithTag = await Database.bookModel.findAll({
|
||||
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:tags))`), {
|
||||
where: Sequelize.where(Sequelize.literal(jsonArrayContainsAny('tags', 'tags', Database.sequelize)), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
}),
|
||||
replacements: {
|
||||
|
|
@ -46,7 +47,7 @@ module.exports = {
|
|||
libraryItems.push(libraryItem)
|
||||
}
|
||||
const podcastsWithTag = await Database.podcastModel.findAll({
|
||||
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:tags))`), {
|
||||
where: Sequelize.where(Sequelize.literal(jsonArrayContainsAny('tags', 'tags', Database.sequelize)), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
}),
|
||||
replacements: {
|
||||
|
|
@ -77,7 +78,7 @@ module.exports = {
|
|||
async getAllLibraryItemsWithGenres(genres) {
|
||||
const libraryItems = []
|
||||
const booksWithGenre = await Database.bookModel.findAll({
|
||||
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(genres) WHERE json_valid(genres) AND json_each.value IN (:genres))`), {
|
||||
where: Sequelize.where(Sequelize.literal(jsonArrayContainsAny('genres', 'genres', Database.sequelize)), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
}),
|
||||
replacements: {
|
||||
|
|
@ -107,7 +108,7 @@ module.exports = {
|
|||
libraryItems.push(libraryItem)
|
||||
}
|
||||
const podcastsWithGenre = await Database.podcastModel.findAll({
|
||||
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(genres) WHERE json_valid(genres) AND json_each.value IN (:genres))`), {
|
||||
where: Sequelize.where(Sequelize.literal(jsonArrayContainsAny('genres', 'genres', Database.sequelize)), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
}),
|
||||
replacements: {
|
||||
|
|
@ -138,7 +139,7 @@ module.exports = {
|
|||
async getAllLibraryItemsWithNarrators(narrators) {
|
||||
const libraryItems = []
|
||||
const booksWithGenre = await Database.bookModel.findAll({
|
||||
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(narrators) WHERE json_valid(narrators) AND json_each.value IN (:narrators))`), {
|
||||
where: Sequelize.where(Sequelize.literal(jsonArrayContainsAny('narrators', 'narrators', Database.sequelize)), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
}),
|
||||
replacements: {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const authorFilters = require('./authorFilters')
|
|||
const ShareManager = require('../../managers/ShareManager')
|
||||
const { profile } = require('../profiler')
|
||||
const stringifySequelizeQuery = require('../stringifySequelizeQuery')
|
||||
const { booleanLiteral, noCaseSortExpression, coalesceFunctionName, jsonArrayContainsAny, jsonArrayContainsValue, jsonArrayExpand } = require('../sqlDialectHelpers')
|
||||
const countCache = new Map()
|
||||
|
||||
module.exports = {
|
||||
|
|
@ -27,10 +28,10 @@ module.exports = {
|
|||
if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {
|
||||
replacements['userTagsSelected'] = user.permissions.itemTagsSelected
|
||||
if (user.permissions.selectedTagsNotAccessible) {
|
||||
bookWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))
|
||||
bookWhere.push(Sequelize.where(Sequelize.literal(jsonArrayContainsAny('tags', 'userTagsSelected', Database.sequelize)), 0))
|
||||
} else {
|
||||
bookWhere.push(
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
|
||||
Sequelize.where(Sequelize.literal(jsonArrayContainsAny('tags', 'userTagsSelected', Database.sequelize)), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
})
|
||||
)
|
||||
|
|
@ -189,7 +190,7 @@ module.exports = {
|
|||
} else if (group === 'explicit') {
|
||||
mediaWhere['explicit'] = true
|
||||
} else if (['genres', 'tags', 'narrators'].includes(group)) {
|
||||
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
|
||||
mediaWhere[group] = Sequelize.where(Sequelize.literal(jsonArrayContainsValue(group, 'filterValue', Database.sequelize)), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
})
|
||||
replacements.filterValue = value
|
||||
|
|
@ -256,9 +257,9 @@ module.exports = {
|
|||
|
||||
const getTitleOrder = () => {
|
||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
return [Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]
|
||||
return [Sequelize.literal(noCaseSortExpression('libraryItem.titleIgnorePrefix', Database.sequelize)), dir]
|
||||
} else {
|
||||
return [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]
|
||||
return [Sequelize.literal(noCaseSortExpression('libraryItem.title', Database.sequelize)), dir]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -273,20 +274,23 @@ module.exports = {
|
|||
} else if (sortBy === 'media.duration') {
|
||||
return [['duration', dir]]
|
||||
} else if (sortBy === 'media.metadata.publishedYear') {
|
||||
return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]]
|
||||
return [[Sequelize.literal('CAST(book.publishedYear AS INTEGER)'), dir]]
|
||||
} else if (sortBy === 'media.metadata.authorNameLF') {
|
||||
// Sort by author name last first, secondary sort by title
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir], getTitleOrder()]
|
||||
return [[Sequelize.literal(noCaseSortExpression('libraryItem.authorNamesLastFirst', Database.sequelize)), dir], getTitleOrder()]
|
||||
} else if (sortBy === 'media.metadata.authorName') {
|
||||
// Sort by author name first last, secondary sort by title
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir], getTitleOrder()]
|
||||
return [[Sequelize.literal(noCaseSortExpression('libraryItem.authorNamesFirstLast', Database.sequelize)), dir], getTitleOrder()]
|
||||
} else if (sortBy === 'media.metadata.title') {
|
||||
if (collapseseries) {
|
||||
return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]
|
||||
return [[Sequelize.literal(noCaseSortExpression('display_title', Database.sequelize)), dir]]
|
||||
}
|
||||
return [getTitleOrder()]
|
||||
} else if (sortBy === 'sequence') {
|
||||
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
|
||||
if (Database.sequelize.getDialect() === 'postgres') {
|
||||
return [[Sequelize.literal(`CAST("series.bookSeries"."sequence" AS DOUBLE PRECISION) ${nullDir}`)]]
|
||||
}
|
||||
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
|
||||
} else if (sortBy === 'progress') {
|
||||
return [[Sequelize.literal(`mediaProgresses.updatedAt ${dir} NULLS LAST`)]]
|
||||
|
|
@ -326,7 +330,13 @@ module.exports = {
|
|||
required: true
|
||||
}
|
||||
],
|
||||
order: [Sequelize.literal('CAST(`books.bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]
|
||||
order: [
|
||||
Sequelize.literal(
|
||||
Database.sequelize.getDialect() === 'postgres'
|
||||
? 'CAST("books.bookSeries"."sequence" AS DOUBLE PRECISION) ASC NULLS LAST'
|
||||
: 'CAST(`books.bookSeries.sequence` AS FLOAT) ASC NULLS LAST'
|
||||
)
|
||||
]
|
||||
})
|
||||
const bookSeriesToInclude = []
|
||||
const booksToInclude = []
|
||||
|
|
@ -455,12 +465,28 @@ module.exports = {
|
|||
})
|
||||
} else if (filterGroup === 'ebooks' && filterValue === 'supplementary') {
|
||||
// TODO: Temp workaround for filtering supplementary ebook
|
||||
libraryItemWhere['libraryFiles'] = {
|
||||
[Sequelize.Op.substring]: `"isSupplementary":true`
|
||||
if (Database.sequelize.getDialect() === 'postgres') {
|
||||
libraryItemWhere[Sequelize.Op.and] = [
|
||||
Sequelize.where(Sequelize.literal('CAST("libraryItem"."libraryFiles" AS TEXT)'), {
|
||||
[Sequelize.Op.like]: '%"isSupplementary":true%'
|
||||
})
|
||||
]
|
||||
} else {
|
||||
libraryItemWhere['libraryFiles'] = {
|
||||
[Sequelize.Op.substring]: `"isSupplementary":true`
|
||||
}
|
||||
}
|
||||
} else if (filterGroup === 'ebooks' && filterValue === 'no-supplementary') {
|
||||
libraryItemWhere['libraryFiles'] = {
|
||||
[Sequelize.Op.notLike]: Sequelize.literal(`\'%"isSupplementary":true%\'`)
|
||||
if (Database.sequelize.getDialect() === 'postgres') {
|
||||
libraryItemWhere[Sequelize.Op.and] = [
|
||||
Sequelize.where(Sequelize.literal('CAST("libraryItem"."libraryFiles" AS TEXT)'), {
|
||||
[Sequelize.Op.notLike]: '%"isSupplementary":true%'
|
||||
})
|
||||
]
|
||||
} else {
|
||||
libraryItemWhere['libraryFiles'] = {
|
||||
[Sequelize.Op.notLike]: Sequelize.literal(`\'%"isSupplementary":true%\'`)
|
||||
}
|
||||
}
|
||||
} else if (filterGroup === 'missing' && filterValue === 'authors') {
|
||||
authorInclude = {
|
||||
|
|
@ -502,7 +528,13 @@ module.exports = {
|
|||
})
|
||||
if (sortBy !== 'sequence') {
|
||||
// Secondary sort by sequence
|
||||
sortOrder.push([Sequelize.literal('CAST(`series.bookSeries.sequence` AS FLOAT) ASC NULLS LAST')])
|
||||
sortOrder.push([
|
||||
Sequelize.literal(
|
||||
Database.sequelize.getDialect() === 'postgres'
|
||||
? 'CAST("series.bookSeries"."sequence" AS DOUBLE PRECISION) ASC NULLS LAST'
|
||||
: 'CAST(`series.bookSeries.sequence` AS FLOAT) ASC NULLS LAST'
|
||||
)
|
||||
])
|
||||
}
|
||||
} else if (filterGroup === 'issues') {
|
||||
libraryItemWhere[Sequelize.Op.or] = [
|
||||
|
|
@ -594,10 +626,14 @@ module.exports = {
|
|||
|
||||
// When collapsing series and sorting by title then use the series name instead of the book title
|
||||
// for this set an attribute "display_title" to use in sorting
|
||||
const fallbackLibraryItemTitle = Database.sequelize.getDialect() === 'postgres' ? '"libraryItem"."title"' : '`libraryItem`.`title`'
|
||||
const fallbackLibraryItemTitleIgnorePrefix = Database.sequelize.getDialect() === 'postgres' ? '"libraryItem"."titleIgnorePrefix"' : '`libraryItem`.`titleIgnorePrefix`'
|
||||
const fallbackFn = coalesceFunctionName(Database.sequelize)
|
||||
|
||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), \`libraryItem\`.\`titleIgnorePrefix\`)`), 'display_title'])
|
||||
bookAttributes.include.push([Sequelize.literal(`${fallbackFn}((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), ${fallbackLibraryItemTitleIgnorePrefix})`), 'display_title'])
|
||||
} else {
|
||||
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), \`libraryItem\`.\`title\`)`), 'display_title'])
|
||||
bookAttributes.include.push([Sequelize.literal(`${fallbackFn}((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), ${fallbackLibraryItemTitle})`), 'display_title'])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -716,13 +752,14 @@ module.exports = {
|
|||
bookWhere.push(...userPermissionBookWhere.bookWhere)
|
||||
|
||||
let includeAttributes = [[Sequelize.literal('(SELECT max(mp.updatedAt) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id)'), 'recent_progress']]
|
||||
let booksNotFinishedQuery = `SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL)`
|
||||
let booksNotFinishedQuery = `SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = ${booleanLiteral(false, Database.sequelize)} OR mp.isFinished IS NULL)`
|
||||
|
||||
if (library.settings.onlyShowLaterBooksInContinueSeries) {
|
||||
const maxSequenceQuery = `(SELECT CAST(max(bs.sequence) as FLOAT) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = 1 AND mp.userId = :userId AND bs.seriesId = series.id)`
|
||||
const castType = Database.sequelize.getDialect() === 'postgres' ? 'DOUBLE PRECISION' : 'FLOAT'
|
||||
const maxSequenceQuery = `(SELECT CAST(max(bs.sequence) as ${castType}) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = ${booleanLiteral(true, Database.sequelize)} AND mp.userId = :userId AND bs.seriesId = series.id)`
|
||||
includeAttributes.push([Sequelize.literal(`${maxSequenceQuery}`), 'maxSequence'])
|
||||
|
||||
booksNotFinishedQuery = booksNotFinishedQuery + ` AND CAST(bs.sequence as FLOAT) > ${maxSequenceQuery}`
|
||||
booksNotFinishedQuery = booksNotFinishedQuery + ` AND CAST(bs.sequence as ${castType}) > ${maxSequenceQuery}`
|
||||
}
|
||||
|
||||
const { rows: series, count } = await Database.seriesModel.findAndCountAll({
|
||||
|
|
@ -735,7 +772,7 @@ module.exports = {
|
|||
},
|
||||
// TODO: Simplify queries
|
||||
// Has at least 1 book finished
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM mediaProgresses mp, bookSeries bs WHERE bs.seriesId = series.id AND mp.mediaItemId = bs.bookId AND mp.userId = :userId AND mp.isFinished = 1)`), {
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM mediaProgresses mp, bookSeries bs WHERE bs.seriesId = series.id AND mp.mediaItemId = bs.bookId AND mp.userId = :userId AND mp.isFinished = ${booleanLiteral(true, Database.sequelize)})`), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
}),
|
||||
// Has at least 1 book not finished (that has a sequence number higher than the highest already read, if library config is toggled)
|
||||
|
|
@ -743,7 +780,7 @@ module.exports = {
|
|||
[Sequelize.Op.gte]: 1
|
||||
}),
|
||||
// Has no books in progress
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM mediaProgresses mp, bookSeries bs WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id AND mp.isFinished = 0 AND mp.currentTime > 0)`), 0)
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM mediaProgresses mp, bookSeries bs WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id AND mp.isFinished = ${booleanLiteral(false, Database.sequelize)} AND mp.currentTime > 0)`), 0)
|
||||
],
|
||||
attributes: {
|
||||
include: includeAttributes
|
||||
|
|
@ -760,7 +797,7 @@ module.exports = {
|
|||
order: [[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]],
|
||||
where: {
|
||||
'$book.mediaProgresses.isFinished$': {
|
||||
[Sequelize.Op.or]: [null, 0]
|
||||
[Sequelize.Op.or]: [null, false]
|
||||
}
|
||||
},
|
||||
include: {
|
||||
|
|
@ -854,7 +891,7 @@ module.exports = {
|
|||
{
|
||||
libraryId
|
||||
},
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId WHERE bs.seriesId = series.id AND mp.userId = :userId AND (mp.isFinished = 1 OR mp.currentTime > 0))`), 0)
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId WHERE bs.seriesId = series.id AND mp.userId = :userId AND (mp.isFinished = ${booleanLiteral(true, Database.sequelize)} OR mp.currentTime > 0))`), 0)
|
||||
],
|
||||
replacements: {
|
||||
userId: user.id,
|
||||
|
|
@ -904,6 +941,7 @@ module.exports = {
|
|||
id: {
|
||||
[Sequelize.Op.in]: booksFromSeriesToInclude
|
||||
}
|
||||
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -1130,7 +1168,7 @@ module.exports = {
|
|||
|
||||
// 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 ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
|
||||
const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, ${jsonArrayExpand('b.narrators', Database.sequelize)} WHERE ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
libraryId: library.id,
|
||||
limit,
|
||||
|
|
@ -1147,7 +1185,7 @@ module.exports = {
|
|||
|
||||
// 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 ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, ${jsonArrayExpand('b.tags', Database.sequelize)} WHERE ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
libraryId: library.id,
|
||||
limit,
|
||||
|
|
@ -1164,7 +1202,7 @@ module.exports = {
|
|||
|
||||
// Search genres
|
||||
const genreMatches = []
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, ${jsonArrayExpand('b.genres', Database.sequelize)} WHERE ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
libraryId: library.id,
|
||||
limit,
|
||||
|
|
@ -1244,7 +1282,7 @@ module.exports = {
|
|||
*/
|
||||
async getGenresWithCount(libraryId) {
|
||||
const genres = []
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, ${jsonArrayExpand('b.genres', Database.sequelize)} WHERE b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {
|
||||
replacements: {
|
||||
libraryId
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const Database = require('../../Database')
|
|||
const Logger = require('../../Logger')
|
||||
const { profile } = require('../../utils/profiler')
|
||||
const stringifySequelizeQuery = require('../stringifySequelizeQuery')
|
||||
const { jsonArrayContainsAny, jsonArrayContainsValue, noCaseSortExpression, jsonArrayExpand, jsonPathNumber } = require('../sqlDialectHelpers')
|
||||
|
||||
const countCache = new Map()
|
||||
|
||||
|
|
@ -24,10 +25,10 @@ module.exports = {
|
|||
if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {
|
||||
replacements['userTagsSelected'] = user.permissions.itemTagsSelected
|
||||
if (user.permissions.selectedTagsNotAccessible) {
|
||||
podcastWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))
|
||||
podcastWhere.push(Sequelize.where(Sequelize.literal(jsonArrayContainsAny('tags', 'userTagsSelected', Database.sequelize)), 0))
|
||||
} else {
|
||||
podcastWhere.push(
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
|
||||
Sequelize.where(Sequelize.literal(jsonArrayContainsAny('tags', 'userTagsSelected', Database.sequelize)), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
})
|
||||
)
|
||||
|
|
@ -53,7 +54,7 @@ module.exports = {
|
|||
const replacements = {}
|
||||
|
||||
if (['genres', 'tags'].includes(group)) {
|
||||
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
|
||||
mediaWhere[group] = Sequelize.where(Sequelize.literal(jsonArrayContainsValue(group, 'filterValue', Database.sequelize)), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
})
|
||||
replacements.filterValue = value
|
||||
|
|
@ -87,12 +88,12 @@ module.exports = {
|
|||
return [[Sequelize.literal('libraryItem.mtime'), dir]]
|
||||
} else if (sortBy === 'media.metadata.author') {
|
||||
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
|
||||
return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]]
|
||||
return [[Sequelize.literal(`${noCaseSortExpression('podcast.author', Database.sequelize)} ${nullDir}`)]]
|
||||
} else if (sortBy === 'media.metadata.title') {
|
||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
|
||||
return [[Sequelize.literal(noCaseSortExpression('libraryItem.titleIgnorePrefix', Database.sequelize)), dir]]
|
||||
} else {
|
||||
return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]]
|
||||
return [[Sequelize.literal(noCaseSortExpression('libraryItem.title', Database.sequelize)), dir]]
|
||||
}
|
||||
} else if (sortBy === 'media.numTracks') {
|
||||
return [['numEpisodes', dir]]
|
||||
|
|
@ -455,7 +456,7 @@ module.exports = {
|
|||
|
||||
// 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 ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, ${jsonArrayExpand('p.tags', Database.sequelize)} WHERE ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
libraryId: library.id,
|
||||
limit,
|
||||
|
|
@ -472,7 +473,7 @@ module.exports = {
|
|||
|
||||
// Search genres
|
||||
const genreMatches = []
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, ${jsonArrayExpand('p.genres', Database.sequelize)} WHERE ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
libraryId: library.id,
|
||||
limit,
|
||||
|
|
@ -563,12 +564,12 @@ module.exports = {
|
|||
* @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
|
||||
*/
|
||||
async getPodcastLibraryStats(libraryId) {
|
||||
const [sizeResults] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize FROM libraryItems li WHERE li.mediaType = "podcast" AND li.libraryId = :libraryId;`, {
|
||||
const [sizeResults] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize FROM libraryItems li WHERE li.mediaType = 'podcast' AND li.libraryId = :libraryId;`, {
|
||||
replacements: {
|
||||
libraryId
|
||||
}
|
||||
})
|
||||
const [statResults] = await Database.sequelize.query(`SELECT SUM(json_extract(pe.audioFile, '$.duration')) AS totalDuration, COUNT(DISTINCT(li.id)) AS totalItems, COUNT(pe.id) AS numAudioFiles FROM libraryItems li, podcasts p LEFT OUTER JOIN podcastEpisodes pe ON pe.podcastId = p.id WHERE p.id = li.mediaId AND li.libraryId = :libraryId;`, {
|
||||
const [statResults] = await Database.sequelize.query(`SELECT SUM(${jsonPathNumber('pe.audioFile', ['duration'], Database.sequelize)}) AS totalDuration, COUNT(DISTINCT(li.id)) AS totalItems, COUNT(pe.id) AS numAudioFiles FROM libraryItems li, podcasts p LEFT OUTER JOIN podcastEpisodes pe ON pe.podcastId = p.id WHERE p.id = li.mediaId AND li.libraryId = :libraryId;`, {
|
||||
replacements: {
|
||||
libraryId
|
||||
}
|
||||
|
|
@ -588,7 +589,7 @@ module.exports = {
|
|||
*/
|
||||
async getGenresWithCount(libraryId) {
|
||||
const genres = []
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, ${jsonArrayExpand('p.genres', Database.sequelize)} WHERE p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {
|
||||
replacements: {
|
||||
libraryId
|
||||
},
|
||||
|
|
@ -611,7 +612,7 @@ module.exports = {
|
|||
*/
|
||||
async getLongestPodcasts(libraryId, limit) {
|
||||
const podcasts = await Database.podcastModel.findAll({
|
||||
attributes: ['id', 'title', [Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']],
|
||||
attributes: ['id', 'title', [Sequelize.literal(`(SELECT SUM(${jsonPathNumber('pe.audioFile', ['duration'], Database.sequelize)}) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']],
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id', 'libraryId'],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const Sequelize = require('sequelize')
|
|||
const Logger = require('../../Logger')
|
||||
const Database = require('../../Database')
|
||||
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
||||
const { booleanLiteral, jsonArrayContainsAny, jsonArrayContainsValue, noCaseSortExpression } = require('../sqlDialectHelpers')
|
||||
|
||||
module.exports = {
|
||||
decode(text) {
|
||||
|
|
@ -60,7 +61,7 @@ module.exports = {
|
|||
// TODO: Simplify and break-out
|
||||
let attrQuery = null
|
||||
if (['genres', 'tags', 'narrators'].includes(filterGroup)) {
|
||||
attrQuery = `SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (SELECT count(*) FROM json_each(b.${filterGroup}) WHERE json_valid(b.${filterGroup}) AND json_each.value = :filterValue) > 0`
|
||||
attrQuery = `SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND ${jsonArrayContainsValue(`b.${filterGroup}`, 'filterValue', Database.sequelize)} > 0`
|
||||
userPermissionBookWhere.replacements.filterValue = filterValue
|
||||
} else if (filterGroup === 'authors') {
|
||||
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs, bookAuthors ba WHERE bs.seriesId = series.id AND bs.bookId = b.id AND ba.bookId = b.id AND ba.authorId = :filterValue'
|
||||
|
|
@ -73,18 +74,18 @@ module.exports = {
|
|||
userPermissionBookWhere.replacements.filterValue = filterValue
|
||||
} else if (filterGroup === 'progress') {
|
||||
if (filterValue === 'not-finished') {
|
||||
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
|
||||
attrQuery = `SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = ${booleanLiteral(false, Database.sequelize)})`
|
||||
userPermissionBookWhere.replacements.userId = user.id
|
||||
} else if (filterValue === 'finished') {
|
||||
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
|
||||
const progQuery = `SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = ${booleanLiteral(false, Database.sequelize)})`
|
||||
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
|
||||
userPermissionBookWhere.replacements.userId = user.id
|
||||
} else if (filterValue === 'not-started') {
|
||||
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)'
|
||||
const progQuery = `SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = ${booleanLiteral(true, Database.sequelize)} OR mp.currentTime > 0)`
|
||||
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
|
||||
userPermissionBookWhere.replacements.userId = user.id
|
||||
} else if (filterValue === 'in-progress') {
|
||||
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0'
|
||||
attrQuery = `SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = ${booleanLiteral(false, Database.sequelize)}`
|
||||
userPermissionBookWhere.replacements.userId = user.id
|
||||
}
|
||||
}
|
||||
|
|
@ -95,13 +96,13 @@ module.exports = {
|
|||
if (!attrQuery) attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id'
|
||||
|
||||
if (!user.canAccessExplicitContent) {
|
||||
attrQuery += ' AND b.explicit = 0'
|
||||
attrQuery += ` AND b.explicit = ${booleanLiteral(false, Database.sequelize)}`
|
||||
}
|
||||
if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {
|
||||
if (user.permissions.selectedTagsNotAccessible) {
|
||||
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0'
|
||||
attrQuery += ` AND ${jsonArrayContainsAny('b.tags', 'userTagsSelected', Database.sequelize)} = 0`
|
||||
} else {
|
||||
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) > 0'
|
||||
attrQuery += ` AND ${jsonArrayContainsAny('b.tags', 'userTagsSelected', Database.sequelize)} > 0`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -128,9 +129,9 @@ module.exports = {
|
|||
order.push(['createdAt', dir])
|
||||
} else if (sortBy === 'name') {
|
||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
order.push([Sequelize.literal('nameIgnorePrefix COLLATE NOCASE'), dir])
|
||||
order.push([Sequelize.literal(noCaseSortExpression('nameIgnorePrefix', Database.sequelize)), dir])
|
||||
} else {
|
||||
order.push([Sequelize.literal('`series`.`name` COLLATE NOCASE'), dir])
|
||||
order.push([Sequelize.literal(noCaseSortExpression('series.name', Database.sequelize)), dir])
|
||||
}
|
||||
} else if (sortBy === 'totalDuration') {
|
||||
seriesAttributes.include.push([Sequelize.literal('(SELECT SUM(b.duration) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'totalDuration'])
|
||||
|
|
|
|||
75
server/utils/sqlDialectHelpers.js
Normal file
75
server/utils/sqlDialectHelpers.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
function getDialect(sequelize) {
|
||||
if (!sequelize) return 'sqlite'
|
||||
if (typeof sequelize.getDialect === 'function') return sequelize.getDialect()
|
||||
return sequelize.dialect?.name || 'sqlite'
|
||||
}
|
||||
|
||||
function isPostgres(sequelize) {
|
||||
return getDialect(sequelize) === 'postgres'
|
||||
}
|
||||
|
||||
function booleanLiteral(value, sequelize) {
|
||||
if (isPostgres(sequelize)) return value ? 'TRUE' : 'FALSE'
|
||||
return value ? '1' : '0'
|
||||
}
|
||||
|
||||
function noCaseSortExpression(columnExpression, sequelize) {
|
||||
if (isPostgres(sequelize)) return `LOWER(${columnExpression})`
|
||||
return `${columnExpression} COLLATE NOCASE`
|
||||
}
|
||||
|
||||
function coalesceFunctionName(sequelize) {
|
||||
return isPostgres(sequelize) ? 'COALESCE' : 'IFNULL'
|
||||
}
|
||||
|
||||
function jsonArrayContainsAny(columnExpression, bindName, sequelize) {
|
||||
if (isPostgres(sequelize)) {
|
||||
return `(SELECT count(*) FROM jsonb_array_elements_text(COALESCE(${columnExpression}::jsonb, '[]'::jsonb)) AS json_each(value) WHERE json_each.value IN (:${bindName}))`
|
||||
}
|
||||
return `(SELECT count(*) FROM json_each(${columnExpression}) WHERE json_valid(${columnExpression}) AND json_each.value IN (:${bindName}))`
|
||||
}
|
||||
|
||||
function jsonArrayContainsValue(columnExpression, bindName, sequelize) {
|
||||
if (isPostgres(sequelize)) {
|
||||
return `(SELECT count(*) FROM jsonb_array_elements_text(COALESCE(${columnExpression}::jsonb, '[]'::jsonb)) AS json_each(value) WHERE json_each.value = :${bindName})`
|
||||
}
|
||||
return `(SELECT count(*) FROM json_each(${columnExpression}) WHERE json_valid(${columnExpression}) AND json_each.value = :${bindName})`
|
||||
}
|
||||
|
||||
function jsonArrayExpand(columnExpression, sequelize, options = {}) {
|
||||
const alias = options.alias || 'json_each'
|
||||
const textValues = options.textValues !== false
|
||||
if (isPostgres(sequelize)) {
|
||||
const fn = textValues ? 'jsonb_array_elements_text' : 'jsonb_array_elements'
|
||||
return `${fn}(COALESCE(${columnExpression}::jsonb, '[]'::jsonb)) AS ${alias}(value)`
|
||||
}
|
||||
return `json_each(${columnExpression})`
|
||||
}
|
||||
|
||||
function jsonPathText(columnExpression, pathSegments, sequelize) {
|
||||
const path = Array.isArray(pathSegments) ? pathSegments : [pathSegments]
|
||||
if (isPostgres(sequelize)) {
|
||||
return `${columnExpression}::jsonb #>> '{${path.join(',')}}'`
|
||||
}
|
||||
return `json_extract(${columnExpression}, '$.${path.join('.')}')`
|
||||
}
|
||||
|
||||
function jsonPathNumber(columnExpression, pathSegments, sequelize) {
|
||||
if (isPostgres(sequelize)) {
|
||||
return `NULLIF(${jsonPathText(columnExpression, pathSegments, sequelize)}, '')::double precision`
|
||||
}
|
||||
return `json_extract(${columnExpression}, '$.${[].concat(pathSegments).join('.')}')`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDialect,
|
||||
isPostgres,
|
||||
booleanLiteral,
|
||||
noCaseSortExpression,
|
||||
coalesceFunctionName,
|
||||
jsonArrayContainsAny,
|
||||
jsonArrayContainsValue,
|
||||
jsonArrayExpand,
|
||||
jsonPathText,
|
||||
jsonPathNumber
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue