1
0
Fork 0

Add ability to search by uuids/actor names

This commit is contained in:
Chocobozzz 2021-07-28 16:40:21 +02:00
parent 164c8d46cf
commit fbd67e7f38
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
15 changed files with 215 additions and 48 deletions

View File

@ -46,7 +46,7 @@ export { searchChannelsRouter }
function searchVideoChannels (req: express.Request, res: express.Response) {
const query: VideoChannelsSearchQuery = req.query
const search = query.search
let search = query.search || ''
const parts = search.split('@')
@ -57,7 +57,7 @@ function searchVideoChannels (req: express.Request, res: express.Response) {
if (isURISearch(search) || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res)
// @username -> username to search in DB
if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
if (search.startsWith('@')) search = search.replace(/^@/, '')
if (isSearchIndexSearch(query)) {
return searchVideoChannelsIndex(query, res)
@ -99,7 +99,8 @@ async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: expr
start: query.start,
count: query.count,
sort: query.sort,
host: query.host
host: query.host,
names: query.names
}, 'filter:api.search.video-channels.local.list.params')
const resultList = await Hooks.wrapPromiseFun(

View File

@ -89,7 +89,8 @@ async function searchVideoPlaylistsDB (query: VideoPlaylistsSearchQuery, res: ex
start: query.start,
count: query.count,
sort: query.sort,
host: query.host
host: query.host,
uuids: query.uuids
}, 'filter:api.search.video-playlists.local.list.params')
const resultList = await Hooks.wrapPromiseFun(

View File

@ -39,6 +39,10 @@ function isUUIDValid (value: string) {
return exists(value) && validator.isUUID('' + value, 4)
}
function areUUIDsValid (values: string[]) {
return isArray(values) && values.every(v => isUUIDValid(v))
}
function isIdOrUUIDValid (value: string) {
return isIdValid(value) || isUUIDValid(value)
}
@ -132,6 +136,10 @@ function toCompleteUUID (value: string) {
return value
}
function toCompleteUUIDs (values: string[]) {
return values.map(v => toCompleteUUID(v))
}
function toIntOrNull (value: string) {
const v = toValueOrNull(value)
@ -180,6 +188,7 @@ export {
isIdValid,
isSafePath,
isUUIDValid,
toCompleteUUIDs,
toCompleteUUID,
isIdOrUUIDValid,
isDateValid,
@ -187,6 +196,7 @@ export {
toBooleanOrNull,
isBooleanValid,
toIntOrNull,
areUUIDsValid,
toArray,
toIntArray,
isFileFieldValid,

View File

@ -2,7 +2,7 @@ import * as express from 'express'
import { query } from 'express-validator'
import { isSearchTargetValid } from '@server/helpers/custom-validators/search'
import { isHostValid } from '@server/helpers/custom-validators/servers'
import { isDateValid } from '../../helpers/custom-validators/misc'
import { areUUIDsValid, isDateValid, toCompleteUUIDs } from '../../helpers/custom-validators/misc'
import { logger } from '../../helpers/logger'
import { areValidationErrors } from './shared'
@ -27,8 +27,18 @@ const videosSearchValidator = [
.optional()
.custom(isDateValid).withMessage('Should have a published end date that conforms to ISO 8601'),
query('durationMin').optional().isInt().withMessage('Should have a valid min duration'),
query('durationMax').optional().isInt().withMessage('Should have a valid max duration'),
query('durationMin')
.optional()
.isInt().withMessage('Should have a valid min duration'),
query('durationMax')
.optional()
.isInt().withMessage('Should have a valid max duration'),
query('uuids')
.optional()
.toArray()
.customSanitizer(toCompleteUUIDs)
.custom(areUUIDsValid).withMessage('Should have valid uuids'),
query('searchTarget').optional().custom(isSearchTargetValid).withMessage('Should have a valid search target'),
@ -42,7 +52,9 @@ const videosSearchValidator = [
]
const videoChannelsListSearchValidator = [
query('search').not().isEmpty().withMessage('Should have a valid search'),
query('search')
.optional()
.not().isEmpty().withMessage('Should have a valid search'),
query('host')
.optional()
@ -52,6 +64,10 @@ const videoChannelsListSearchValidator = [
.optional()
.custom(isSearchTargetValid).withMessage('Should have a valid search target'),
query('names')
.optional()
.toArray(),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking video channels search query', { parameters: req.query })
@ -62,7 +78,9 @@ const videoChannelsListSearchValidator = [
]
const videoPlaylistsListSearchValidator = [
query('search').not().isEmpty().withMessage('Should have a valid search'),
query('search')
.optional()
.not().isEmpty().withMessage('Should have a valid search'),
query('host')
.optional()
@ -72,6 +90,12 @@ const videoPlaylistsListSearchValidator = [
.optional()
.custom(isSearchTargetValid).withMessage('Should have a valid search target'),
query('uuids')
.optional()
.toArray()
.customSanitizer(toCompleteUUIDs)
.custom(areUUIDsValid).withMessage('Should have valid uuids'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking video playlists search query', { parameters: req.query })

View File

@ -35,6 +35,8 @@ export type BuildVideosListQueryOptions = {
tagsOneOf?: string[]
tagsAllOf?: string[]
uuids?: string[]
withFiles?: boolean
accountId?: number
@ -161,6 +163,10 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
this.whereTagsAllOf(options.tagsAllOf)
}
if (options.uuids) {
this.whereUUIDs(options.uuids)
}
if (options.nsfw === true) {
this.whereNSFW()
} else if (options.nsfw === false) {
@ -386,6 +392,10 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
)
}
private whereUUIDs (uuids: string[]) {
this.and.push('"video"."uuid" IN (' + createSafeIn(this.sequelize, uuids) + ')')
}
private whereCategoryOneOf (categoryOneOf: number[]) {
this.and.push('"video"."category" IN (:categoryOneOf)')
this.replacements.categoryOneOf = categoryOneOf

View File

@ -59,6 +59,7 @@ type AvailableForListOptions = {
actorId: number
search?: string
host?: string
names?: string[]
}
type AvailableWithStatsOptions = {
@ -84,18 +85,20 @@ export type SummaryOptions = {
// Only list local channels OR channels that are on an instance followed by actorId
const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
const whereActor = {
[Op.or]: [
{
serverId: null
},
{
serverId: {
[Op.in]: Sequelize.literal(inQueryInstanceFollow)
const whereActorAnd: WhereOptions[] = [
{
[Op.or]: [
{
serverId: null
},
{
serverId: {
[Op.in]: Sequelize.literal(inQueryInstanceFollow)
}
}
}
]
}
]
}
]
let serverRequired = false
let whereServer: WhereOptions
@ -106,8 +109,16 @@ export type SummaryOptions = {
}
if (options.host === WEBSERVER.HOST) {
Object.assign(whereActor, {
[Op.and]: [ { serverId: null } ]
whereActorAnd.push({
serverId: null
})
}
if (options.names) {
whereActorAnd.push({
preferredUsername: {
[Op.in]: options.names
}
})
}
@ -118,7 +129,9 @@ export type SummaryOptions = {
exclude: unusedActorAttributesForAPI
},
model: ActorModel,
where: whereActor,
where: {
[Op.and]: whereActorAnd
},
include: [
{
model: ServerModel,
@ -454,26 +467,23 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
static searchForApi (options: {
actorId: number
search: string
search?: string
start: number
count: number
sort: string
host?: string
names?: string[]
}) {
const attributesInclude = []
const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
let attributesInclude: any[] = [ literal('0 as similarity') ]
let where: WhereOptions
const query = {
attributes: {
include: attributesInclude
},
offset: options.start,
limit: options.count,
order: getSort(options.sort),
where: {
if (options.search) {
const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
attributesInclude = [ createSimilarityAttribute('VideoChannelModel.name', options.search) ]
where = {
[Op.or]: [
Sequelize.literal(
'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
@ -485,9 +495,19 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
}
}
const query = {
attributes: {
include: attributesInclude
},
offset: options.start,
limit: options.count,
order: getSort(options.sort),
where
}
return VideoChannelModel
.scope({
method: [ ScopeNames.FOR_API, { actorId: options.actorId, host: options.host } as AvailableForListOptions ]
method: [ ScopeNames.FOR_API, { actorId: options.actorId, host: options.host, names: options.names } as AvailableForListOptions ]
})
.findAndCountAll(query)
.then(({ rows, count }) => {

View File

@ -83,6 +83,7 @@ type AvailableForListOptions = {
listMyPlaylists?: boolean
search?: string
host?: string
uuids?: string[]
withVideos?: boolean
}
@ -200,18 +201,26 @@ function getVideoLengthSelect () {
})
}
if (options.uuids) {
whereAnd.push({
uuid: {
[Op.in]: options.uuids
}
})
}
if (options.withVideos === true) {
whereAnd.push(
literal(`(${getVideoLengthSelect()}) != 0`)
)
}
const attributesInclude = []
let attributesInclude: any[] = [ literal('0 as similarity') ]
if (options.search) {
const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
attributesInclude.push(createSimilarityAttribute('VideoPlaylistModel.name', options.search))
attributesInclude = [ createSimilarityAttribute('VideoPlaylistModel.name', options.search) ]
whereAnd.push({
[Op.or]: [
@ -359,6 +368,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
listMyPlaylists?: boolean
search?: string
host?: string
uuids?: string[]
withVideos?: boolean // false by default
}) {
const query = {
@ -379,6 +389,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
listMyPlaylists: options.listMyPlaylists,
search: options.search,
host: options.host,
uuids: options.uuids,
withVideos: options.withVideos || false
} as AvailableForListOptions
]
@ -402,6 +413,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
sort: string
search?: string
host?: string
uuids?: string[]
}) {
return VideoPlaylistModel.listForApi({
...options,

View File

@ -1132,6 +1132,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
durationMax?: number // seconds
user?: MUserAccountId
filter?: VideoFilter
uuids?: string[]
}) {
const serverActor = await getServerActor()
@ -1167,6 +1168,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
durationMin: options.durationMin,
durationMax: options.durationMax,
uuids: options.uuids,
search: options.search
}

View File

@ -146,6 +146,16 @@ describe('Test videos API validator', function () {
const customQuery = { ...query, host: 'example.com' }
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.OK_200 })
})
it('Should fail with invalid uuids', async function () {
const customQuery = { ...query, uuids: [ '6565', 'dfd70b83-639f-4980-94af-304a56ab4b35' ] }
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should succeed with valid uuids', async function () {
const customQuery = { ...query, uuids: [ 'dfd70b83-639f-4980-94af-304a56ab4b35' ] }
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.OK_200 })
})
})
describe('When searching video playlists', function () {
@ -172,6 +182,11 @@ describe('Test videos API validator', function () {
await makeGetRequest({ url: server.url, path, query: { ...query, host: '6565' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should fail with invalid uuids', async function () {
const customQuery = { ...query, uuids: [ '6565', 'dfd70b83-639f-4980-94af-304a56ab4b35' ] }
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should succeed with the correct parameters', async function () {
await makeGetRequest({ url: server.url, path, query, expectedStatus: HttpStatusCode.OK_200 })
})

View File

@ -22,8 +22,12 @@ describe('Test channels search', function () {
before(async function () {
this.timeout(120000)
server = await createSingleServer(1)
remoteServer = await createSingleServer(2, { transcoding: { enabled: false } })
const servers = await Promise.all([
createSingleServer(1),
createSingleServer(2, { transcoding: { enabled: false } })
])
server = servers[0]
remoteServer = servers[1]
await setAccessTokensToServers([ server, remoteServer ])
@ -116,6 +120,22 @@ describe('Test channels search', function () {
}
})
it('Should filter by names', async function () {
{
const body = await command.advancedChannelSearch({ search: { names: [ 'squall_channel', 'zell_channel' ] } })
expect(body.total).to.equal(2)
expect(body.data).to.have.lengthOf(2)
expect(body.data[0].displayName).to.equal('Squall channel')
expect(body.data[1].displayName).to.equal('Zell channel')
}
{
const body = await command.advancedChannelSearch({ search: { names: [ 'chocobozzz_channel' ] } })
expect(body.total).to.equal(0)
expect(body.data).to.have.lengthOf(0)
}
})
after(async function () {
await cleanupTests([ server ])
})

View File

@ -19,12 +19,18 @@ describe('Test playlists search', function () {
let server: PeerTubeServer
let remoteServer: PeerTubeServer
let command: SearchCommand
let playlistUUID: string
let playlistShortUUID: string
before(async function () {
this.timeout(120000)
server = await createSingleServer(1)
remoteServer = await createSingleServer(2, { transcoding: { enabled: false } })
const servers = await Promise.all([
createSingleServer(1),
createSingleServer(2, { transcoding: { enabled: false } })
])
server = servers[0]
remoteServer = servers[1]
await setAccessTokensToServers([ remoteServer, server ])
await setDefaultVideoChannel([ remoteServer, server ])
@ -38,6 +44,8 @@ describe('Test playlists search', function () {
videoChannelId: server.store.channel.id
}
const created = await server.playlists.create({ attributes })
playlistUUID = created.uuid
playlistShortUUID = created.shortUUID
await server.playlists.addElement({ playlistId: created.id, attributes: { videoId } })
}
@ -136,6 +144,22 @@ describe('Test playlists search', function () {
}
})
it('Should filter by UUIDs', async function () {
for (const uuid of [ playlistUUID, playlistShortUUID ]) {
const body = await command.advancedPlaylistSearch({ search: { uuids: [ uuid ] } })
expect(body.total).to.equal(1)
expect(body.data[0].displayName).to.equal('Dr. Kenzo Tenma hospital videos')
}
{
const body = await command.advancedPlaylistSearch({ search: { uuids: [ 'dfd70b83-639f-4980-94af-304a56ab4b35' ] } })
expect(body.total).to.equal(0)
expect(body.data).to.have.lengthOf(0)
}
})
it('Should not display playlists without videos', async function () {
const search = {
search: 'Lunge',

View File

@ -22,14 +22,19 @@ describe('Test videos search', function () {
let remoteServer: PeerTubeServer
let startDate: string
let videoUUID: string
let videoShortUUID: string
let command: SearchCommand
before(async function () {
this.timeout(120000)
server = await createSingleServer(1)
remoteServer = await createSingleServer(2)
const servers = await Promise.all([
createSingleServer(1),
createSingleServer(2)
])
server = servers[0]
remoteServer = servers[1]
await setAccessTokensToServers([ server, remoteServer ])
await setDefaultVideoChannel([ server, remoteServer ])
@ -50,8 +55,9 @@ describe('Test videos search', function () {
{
const attributes3 = { ...attributes1, name: attributes1.name + ' - 3', language: undefined }
const { id, uuid } = await server.videos.upload({ attributes: attributes3 })
const { id, uuid, shortUUID } = await server.videos.upload({ attributes: attributes3 })
videoUUID = uuid
videoShortUUID = shortUUID
await server.captions.add({
language: 'en',
@ -479,6 +485,22 @@ describe('Test videos search', function () {
expect(body.data[0].name).to.equal('1111 2222 3333 - 3')
})
it('Should filter by UUIDs', async function () {
for (const uuid of [ videoUUID, videoShortUUID ]) {
const body = await command.advancedVideoSearch({ search: { uuids: [ uuid ] } })
expect(body.total).to.equal(1)
expect(body.data[0].name).to.equal('1111 2222 3333 - 3')
}
{
const body = await command.advancedVideoSearch({ search: { uuids: [ 'dfd70b83-639f-4980-94af-304a56ab4b35' ] } })
expect(body.total).to.equal(0)
expect(body.data).to.have.lengthOf(0)
}
})
it('Should search by host', async function () {
{
const body = await command.advancedVideoSearch({ search: { search: '6666 7777 8888', host: server.host } })

View File

@ -1,11 +1,12 @@
import { SearchTargetQuery } from './search-target-query.model'
export interface VideoChannelsSearchQuery extends SearchTargetQuery {
search: string
search?: string
start?: number
count?: number
sort?: string
host?: string
names?: string[]
}

View File

@ -1,11 +1,12 @@
import { SearchTargetQuery } from './search-target-query.model'
export interface VideoPlaylistsSearchQuery extends SearchTargetQuery {
search: string
search?: string
start?: number
count?: number
sort?: string
host?: string
uuids?: string[]
}

View File

@ -14,4 +14,7 @@ export interface VideosSearchQuery extends SearchTargetQuery, VideosCommonQuery
durationMin?: number // seconds
durationMax?: number // seconds
// UUIDs or short
uuids?: string[]
}