]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add ability to search by uuids/actor names
authorChocobozzz <me@florianbigard.com>
Wed, 28 Jul 2021 14:40:21 +0000 (16:40 +0200)
committerChocobozzz <me@florianbigard.com>
Wed, 28 Jul 2021 14:40:21 +0000 (16:40 +0200)
15 files changed:
server/controllers/api/search/search-video-channels.ts
server/controllers/api/search/search-video-playlists.ts
server/helpers/custom-validators/misc.ts
server/middlewares/validators/search.ts
server/models/video/sql/videos-id-list-query-builder.ts
server/models/video/video-channel.ts
server/models/video/video-playlist.ts
server/models/video/video.ts
server/tests/api/check-params/search.ts
server/tests/api/search/search-channels.ts
server/tests/api/search/search-playlists.ts
server/tests/api/search/search-videos.ts
shared/models/search/video-channels-search-query.model.ts
shared/models/search/video-playlists-search-query.model.ts
shared/models/search/videos-search-query.model.ts

index be0b6b9a27a268104504a3d3107af28c607a2d16..9fc2d53a55bad24e7c0720f7744a39e46ab555a3 100644 (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(
index 60d1a44f76af8d75d1c7727dec5f624e1977223e..bd6a2a5649a96df0e238814468d0630e782a5f23 100644 (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(
index 528bfcfb851eec54d815177402ae0860c8e4c9eb..f8f168149e9cfeb7078e40d43eb725896ee74bc4 100644 (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,
index ea6a490b264fd930385a6eebccf029dd4fc40420..cde300968949a6d333e58b4f2601d62345f58973 100644 (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 })
 
index d4260c69c6e274434893d976ab1fdca22974841f..7625c003d0fbc6cc4d9a90f3951e7d0798987c91 100644 (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
index 9aa27171113beb33a0de49d100a99924694c2bde..327f493048f742fc070eafc6621be70d0cb069f0 100644 (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 }) => {
index a2dc7075d59587a205b176b641a10bd414fd28b4..caa79952de743acde24c5a4e76397920843b3e09 100644 (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,
index c444f381e7c7e2655e90b2a4c332f67e10d7d15f..fe92ead043103547a384cad2bf7893a03a3b43bd 100644 (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
     }
 
index 72ad6c842b2e568f2b87fb6bfb1f14f535ee46cb..789ea7754f975088b66a482205ed05733e19a03a 100644 (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 })
     })
index aab03bfd13dc9c2d46425b666a361188300881da..ef78c0f67e80f49d6a478b3e1d6f94b71e64f558 100644 (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 ])
   })
index e7e53ff411afee0b1b2a88f9273b9b2975f1fa20..85be1eb59fc04bdaa93a563c04082bf306f8a9ce 100644 (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',
index a56dc1d87b71cc6301dba562d46bdf7cd65da311..bd1e4d266eabd6a109442a7eb01bbb056c3a4050 100644 (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 } })
index 2622dfbc60cccb572286a509df7e2e09356d20dc..50c59d41dec28f7552ace13cf5c9a2dd96672c6b 100644 (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[]
 }
index dcf66e9e3c1764cfd3db7e719ddb479c40b75b30..55393c92aeae03ba2c28b316528b9867ba7a1f76 100644 (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[]
 }
index a568c960e0a5a91c7cee8de0e6ba4d4a51e6186d..736d895778b9ad0880003b5791b976c5a7c3a8a7 100644 (file)
@@ -14,4 +14,7 @@ export interface VideosSearchQuery extends SearchTargetQuery, VideosCommonQuery
 
   durationMin?: number // seconds
   durationMax?: number // seconds
+
+  // UUIDs or short
+  uuids?: string[]
 }