mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-03 00:06:46 +01:00
Update search endpoints to search db directly
This commit is contained in:
parent
b334d40998
commit
c77cead9ae
@ -36,7 +36,7 @@ export default {
|
|||||||
return this.narrator?.name || ''
|
return this.narrator?.name || ''
|
||||||
},
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this.narrator?.books?.length || 0
|
return this.narrator?.numBooks || this.narrator?.books?.length || 0
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
@ -103,7 +103,7 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
totalResults() {
|
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: {
|
methods: {
|
||||||
|
@ -44,6 +44,21 @@ class Database {
|
|||||||
return this.models.series
|
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() {
|
async checkHasDb() {
|
||||||
if (!await fs.pathExists(this.dbPath)) {
|
if (!await fs.pathExists(this.dbPath)) {
|
||||||
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
|
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
|
||||||
|
@ -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) {
|
if (!req.query.q) {
|
||||||
return res.status(400).send('No query string')
|
return res.status(400).send('No query string')
|
||||||
}
|
}
|
||||||
const libraryItems = req.libraryItems
|
const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
|
||||||
const maxResults = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
|
const query = req.query.q.trim().toLowerCase()
|
||||||
|
|
||||||
const itemMatches = []
|
const matches = await libraryItemFilters.search(req.library, query, limit)
|
||||||
const authorMatches = {}
|
res.json(matches)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async stats(req, res) {
|
async stats(req, res) {
|
||||||
|
@ -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/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/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/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/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/authors', LibraryController.middlewareNew.bind(this), LibraryController.getAuthors.bind(this))
|
||||||
this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this))
|
this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this))
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
const Sequelize = require('sequelize')
|
const Sequelize = require('sequelize')
|
||||||
const Database = require('../../Database')
|
const Database = require('../../Database')
|
||||||
|
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
||||||
|
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
/**
|
/**
|
||||||
@ -127,7 +129,7 @@ module.exports = {
|
|||||||
/**
|
/**
|
||||||
* Get all library items that have narrators
|
* Get all library items that have narrators
|
||||||
* @param {string[]} narrators
|
* @param {string[]} narrators
|
||||||
* @returns {Promise<LibraryItem[]>}
|
* @returns {Promise<import('../../models/LibraryItem')[]>}
|
||||||
*/
|
*/
|
||||||
async getAllLibraryItemsWithNarrators(narrators) {
|
async getAllLibraryItemsWithNarrators(narrators) {
|
||||||
const libraryItems = []
|
const libraryItems = []
|
||||||
@ -162,5 +164,20 @@ module.exports = {
|
|||||||
libraryItems.push(libraryItem)
|
libraryItems.push(libraryItem)
|
||||||
}
|
}
|
||||||
return libraryItems
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -918,12 +918,205 @@ module.exports = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get library items for series
|
* Get library items for series
|
||||||
* @param {oldSeries} oldSeries
|
* @param {import('../../objects/entities/Series')} oldSeries
|
||||||
* @param {[oldUser]} oldUser
|
* @param {import('../../objects/user/User')} [oldUser]
|
||||||
* @returns {Promise<oldLibraryItem[]>}
|
* @returns {Promise<import('../../objects/LibraryItem')[]>}
|
||||||
*/
|
*/
|
||||||
async getLibraryItemsForSeries(oldSeries, oldUser) {
|
async getLibraryItemsForSeries(oldSeries, oldUser) {
|
||||||
const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, oldUser, 'series', oldSeries.id, null, null, false, [], null, null)
|
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))
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -291,5 +291,104 @@ module.exports = {
|
|||||||
libraryItems,
|
libraryItems,
|
||||||
count
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user